From 8d55bd62d33e42e11d938a55269862a26421222e Mon Sep 17 00:00:00 2001 From: Yassine Hmidi Date: Sat, 11 Oct 2025 05:43:27 +0100 Subject: [PATCH 01/31] implemented agent's memory + migrated to gpt-4o + integrated artemis tools (create competency, list competencies etc..) --- .../atlas/config/AtlasAgentToolConfig.java | 49 ++++ .../atlas/dto/AtlasAgentChatResponseDTO.java | 4 +- .../atlas/dto/ChatHistoryMessageDTO.java | 19 ++ .../atlas/service/AtlasAgentService.java | 69 +++++- .../atlas/service/AtlasAgentToolsService.java | 230 ++++++++++++++++++ .../artemis/atlas/web/AtlasAgentResource.java | 68 +++++- .../prompts/atlas/agent_system_prompt.st | 38 +-- .../agent-chat-modal.component.ts | 70 +++++- .../agent-chat-modal/agent-chat.service.ts | 35 ++- .../competency-management.component.ts | 7 + 10 files changed, 539 insertions(+), 50 deletions(-) create mode 100644 src/main/java/de/tum/cit/aet/artemis/atlas/config/AtlasAgentToolConfig.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/atlas/dto/ChatHistoryMessageDTO.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/config/AtlasAgentToolConfig.java b/src/main/java/de/tum/cit/aet/artemis/atlas/config/AtlasAgentToolConfig.java new file mode 100644 index 000000000000..5db7c94940e3 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/config/AtlasAgentToolConfig.java @@ -0,0 +1,49 @@ +package de.tum.cit.aet.artemis.atlas.config; + +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.chat.memory.MessageWindowChatMemory; +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 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. + */ +@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(); + } + + /** + * In-memory chat memory for temporary session-based conversation storage. + * This stores chat history in memory (not database) and is used to maintain + * conversation context when the user closes and reopens the chat popup. + * The memory is temporary and will be cleared when the server restarts. + * Keeps the last 20 messages per conversation by default. + * + * @return ChatMemory instance for temporary conversation storage + */ + @Bean + public ChatMemory chatMemory() { + return MessageWindowChatMemory.builder().maxMessages(20).build(); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/AtlasAgentChatResponseDTO.java b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/AtlasAgentChatResponseDTO.java index c6ab06b39494..126ba5c09932 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/AtlasAgentChatResponseDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/AtlasAgentChatResponseDTO.java @@ -20,7 +20,9 @@ public record AtlasAgentChatResponseDTO( @NotNull ZonedDateTime timestamp, - boolean success + boolean success, + + Boolean competenciesModified ) { } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/ChatHistoryMessageDTO.java b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/ChatHistoryMessageDTO.java new file mode 100644 index 000000000000..d62deac85e51 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/ChatHistoryMessageDTO.java @@ -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 + +) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java index e0d07bbf013c..e362f7d796c0 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java @@ -1,5 +1,6 @@ package de.tum.cit.aet.artemis.atlas.service; +import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -7,6 +8,10 @@ import org.slf4j.LoggerFactory; import org.springframework.ai.azure.openai.AzureOpenAiChatOptions; import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.chat.messages.Message; +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; @@ -29,40 +34,86 @@ public class AtlasAgentService { private final AtlasPromptTemplateService templateService; - public AtlasAgentService(@Autowired(required = false) ChatClient chatClient, AtlasPromptTemplateService templateService) { + private final ToolCallbackProvider toolCallbackProvider; + + private final ChatMemory chatMemory; + + public AtlasAgentService(@Autowired(required = false) ChatClient chatClient, AtlasPromptTemplateService templateService, + @Autowired(required = false) ToolCallbackProvider toolCallbackProvider, @Autowired(required = false) ChatMemory chatMemory) { this.chatClient = chatClient; this.templateService = templateService; + this.toolCallbackProvider = toolCallbackProvider; + this.chatMemory = chatMemory; } /** * Process a chat message for the given course and return AI response. + * Uses session-based memory to maintain conversation context across popup close/reopen events. * - * @param message The user's message - * @param courseId The course ID for context + * @param message The user's message + * @param courseId The course ID for context + * @param sessionId The session ID for maintaining conversation memory * @return AI response */ - public CompletableFuture processChatMessage(String message, Long courseId) { + public CompletableFuture processChatMessage(String message, Long courseId, String sessionId) { try { - log.debug("Processing chat message for course {} (messageLength={} chars)", courseId, message.length()); + log.debug("Processing chat message for course {} with session {} (messageLength={} chars)", courseId, sessionId, message.length()); // Load system prompt from external template String resourcePath = "/prompts/atlas/agent_system_prompt.st"; Map 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 chat memory advisor for conversation history (temporary session-based caching) + if (chatMemory != null) { + promptSpec = promptSpec.advisors(MessageChatMemoryAdvisor.builder(chatMemory).conversationId(sessionId).build()); + } - log.info("Successfully processed chat message for course {}", courseId); + // Add tools if available + // Note: Use toolCallbacks() for ToolCallbackProvider, not tools() which expects tool objects + if (toolCallbackProvider != null) { + promptSpec = promptSpec.toolCallbacks(toolCallbackProvider); + } + + String response = promptSpec.call().content(); + + log.info("Successfully processed chat message for course {} with session {}", courseId, sessionId); return CompletableFuture.completedFuture(response != null && !response.trim().isEmpty() ? response : "I apologize, but I couldn't generate a response."); } catch (Exception e) { - log.error("Error processing chat message for course {}: {}", courseId, e.getMessage(), e); + log.error("Error processing chat message for course {} with session {}: {}", courseId, sessionId, e.getMessage(), e); return CompletableFuture.completedFuture("I apologize, but I'm having trouble processing your request right now. Please try again later."); } } + /** + * Retrieve conversation history for a given session. + * + * @param sessionId The session ID to retrieve history for + * @return List of messages from the conversation history + */ + public List getConversationHistory(String sessionId) { + try { + if (chatMemory == null) { + log.debug("ChatMemory not available, returning empty history"); + return List.of(); + } + List messages = chatMemory.get(sessionId); + log.debug("Retrieved {} messages from history for session {}", messages.size(), sessionId); + return messages; + } + catch (Exception e) { + log.error("Error retrieving conversation history for session {}: {}", sessionId, e.getMessage(), e); + return List.of(); + } + } + /** * Check if the Atlas Agent service is available and properly configured. * diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java new file mode 100644 index 000000000000..d9c2f86ecf3f --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java @@ -0,0 +1,230 @@ +package de.tum.cit.aet.artemis.atlas.service; + +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.stereotype.Service; + +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. + * Note: Not marked as @Lazy to ensure @Tool methods are properly scanned by MethodToolCallbackProvider. + */ +@Service +@Conditional(AtlasEnabled.class) +public class AtlasAgentToolsService { + + private static final Logger log = LoggerFactory.getLogger(AtlasAgentToolsService.class); + + private final CompetencyRepository competencyRepository; + + private final CourseRepository courseRepository; + + private final ExerciseRepository exerciseRepository; + + // Thread-safe flag to track if competencies were modified in current request + private static final ThreadLocal competenciesModified = ThreadLocal.withInitial(() -> false); + + public AtlasAgentToolsService(CompetencyRepository competencyRepository, CourseRepository courseRepository, ExerciseRepository exerciseRepository) { + this.competencyRepository = competencyRepository; + this.courseRepository = courseRepository; + this.exerciseRepository = exerciseRepository; + } + + /** + * Check if competencies were modified in the current request. + * + * @return true if competencies were created/modified + */ + public static boolean wereCompetenciesModified() { + return competenciesModified.get(); + } + + /** + * Reset the competencies modified flag. Should be called at the start of each request. + */ + public static void resetCompetenciesModified() { + competenciesModified.set(false); + } + + /** + * Clean up ThreadLocal to prevent memory leaks. + */ + public static void cleanup() { + competenciesModified.remove(); + } + + /** + * 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 { + log.debug("Agent tool: Getting competencies for course {}", courseId); + + Optional courseOpt = courseRepository.findById(courseId); + if (courseOpt.isEmpty()) { + return "{\"error\": \"Course not found with ID: " + courseId + "\"}"; + } + + Set competencies = competencyRepository.findAllByCourseId(courseId); + + StringBuilder result = new StringBuilder(); + result.append("{\"courseId\": ").append(courseId).append(", \"competencies\": ["); + + competencies.forEach(competency -> result.append("{").append("\"id\": ").append(competency.getId()).append(", ").append("\"title\": \"") + .append(escapeJson(competency.getTitle())).append("\", ").append("\"description\": \"").append(escapeJson(competency.getDescription())).append("\", ") + .append("\"taxonomy\": \"").append(competency.getTaxonomy() != null ? competency.getTaxonomy() : "").append("\"").append("}, ")); + + if (!competencies.isEmpty()) { + result.setLength(result.length() - 2); // Remove last comma + } + + result.append("]}"); + return result.toString(); + } + catch (Exception e) { + log.error("Error getting course competencies for course {}: {}", courseId, e.getMessage(), e); + return "{\"error\": \"Failed to retrieve competencies: " + e.getMessage() + "\"}"; + } + } + + /** + * Tool for creating a new competency in a course. + */ + @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) { + try { + log.debug("Agent tool: Creating competency '{}' for course {}", title, courseId); + + Optional courseOpt = courseRepository.findById(courseId); + if (courseOpt.isEmpty()) { + return "{\"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); + } + } + + Competency savedCompetency = competencyRepository.save(competency); + + // Mark that competencies were modified in this request + competenciesModified.set(true); + + return String.format(""" + { + "success": true, + "competency": { + "id": %d, + "title": "%s", + "description": "%s", + "taxonomy": "%s", + "courseId": %d + } + } + """, savedCompetency.getId(), escapeJson(savedCompetency.getTitle()), escapeJson(savedCompetency.getDescription()), + savedCompetency.getTaxonomy() != null ? savedCompetency.getTaxonomy() : "", courseId); + } + catch (Exception e) { + log.error("Error creating competency for course {}: {}", courseId, e.getMessage(), e); + return "{\"error\": \"Failed to create competency: " + e.getMessage() + "\"}"; + } + } + + /** + * Tool for getting course description. + */ + @Tool(description = "Get the description of a course") + public String getCourseDescription(@ToolParam(description = "the ID of the course") Long courseId) { + try { + log.debug("Agent tool: Getting course description for course {}", courseId); + + Optional 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 ""; + } + } + + /** + * Tool for getting exercises for a course. + */ + @Tool(description = "List exercises for a course") + public String getExercisesListed(@ToolParam(description = "the ID of the course") Long courseId) { + try { + log.debug("Agent tool: Getting exercises for course {}", courseId); + + Optional courseOpt = courseRepository.findById(courseId); + if (courseOpt.isEmpty()) { + return "{\"error\": \"Course not found with ID: " + courseId + "\"}"; + } + + // NOTE: adapt to your ExerciseRepository method + Set exercises = exerciseRepository.findByCourseIds(Set.of(courseId)); + + StringBuilder result = new StringBuilder(); + result.append("{\"courseId\": ").append(courseId).append(", \"exercises\": ["); + + exercises.forEach(exercise -> result.append("{").append("\"id\": ").append(exercise.getId()).append(", ").append("\"title\": \"") + .append(escapeJson(exercise.getTitle())).append("\", ").append("\"type\": \"").append(exercise.getClass().getSimpleName()).append("\", ") + .append("\"maxPoints\": ").append(exercise.getMaxPoints() != null ? exercise.getMaxPoints() : 0).append(", ").append("\"releaseDate\": \"") + .append(exercise.getReleaseDate() != null ? exercise.getReleaseDate().toString() : "").append("\", ").append("\"dueDate\": \"") + .append(exercise.getDueDate() != null ? exercise.getDueDate().toString() : "").append("\"").append("}, ")); + + if (!exercises.isEmpty()) { + result.setLength(result.length() - 2); + } + + result.append("]}"); + return result.toString(); + } + catch (Exception e) { + log.error("Error getting exercises for course {}: {}", courseId, e.getMessage(), e); + return "{\"error\": \"Failed to retrieve exercises: " + e.getMessage() + "\"}"; + } + } + + private String escapeJson(String input) { + if (input == null) + return ""; + return input.replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t"); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java b/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java index 7c7816d93007..a51d080c0333 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java @@ -1,18 +1,25 @@ package de.tum.cit.aet.artemis.atlas.web; import java.time.ZonedDateTime; +import java.util.List; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.stream.Collectors; import jakarta.validation.Valid; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Lazy; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -22,7 +29,9 @@ import de.tum.cit.aet.artemis.atlas.config.AtlasEnabled; import de.tum.cit.aet.artemis.atlas.dto.AtlasAgentChatRequestDTO; import de.tum.cit.aet.artemis.atlas.dto.AtlasAgentChatResponseDTO; +import de.tum.cit.aet.artemis.atlas.dto.ChatHistoryMessageDTO; import de.tum.cit.aet.artemis.atlas.service.AtlasAgentService; +import de.tum.cit.aet.artemis.atlas.service.AtlasAgentToolsService; import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInCourse.EnforceAtLeastInstructorInCourse; /** @@ -58,25 +67,74 @@ public ResponseEntity sendChatMessage(@PathVariable L log.debug("Received chat message for course {}: {}", courseId, request.message().substring(0, Math.min(request.message().length(), 50))); try { - final var future = atlasAgentService.processChatMessage(request.message(), courseId); + // Reset competencies modified flag at start of request + AtlasAgentToolsService.resetCompetenciesModified(); + + final var future = atlasAgentService.processChatMessage(request.message(), courseId, request.sessionId()); final String response = future.get(CHAT_TIMEOUT_SECONDS, TimeUnit.SECONDS); - return ResponseEntity.ok(new AtlasAgentChatResponseDTO(response, request.sessionId(), ZonedDateTime.now(), true)); + + // Check if competencies were modified via ThreadLocal flag + boolean competenciesModified = AtlasAgentToolsService.wereCompetenciesModified(); + + return ResponseEntity.ok(new AtlasAgentChatResponseDTO(response, request.sessionId(), ZonedDateTime.now(), true, competenciesModified)); } catch (TimeoutException te) { log.warn("Chat timed out for course {}: {}", courseId, te.getMessage()); return ResponseEntity.status(HttpStatus.GATEWAY_TIMEOUT) - .body(new AtlasAgentChatResponseDTO("The agent timed out. Please try again.", request.sessionId(), ZonedDateTime.now(), false)); + .body(new AtlasAgentChatResponseDTO("The agent timed out. Please try again.", request.sessionId(), ZonedDateTime.now(), false, false)); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); log.warn("Chat interrupted for course {}: {}", courseId, ie.getMessage()); return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) - .body(new AtlasAgentChatResponseDTO("The request was interrupted. Please try again.", request.sessionId(), ZonedDateTime.now(), false)); + .body(new AtlasAgentChatResponseDTO("The request was interrupted. Please try again.", request.sessionId(), ZonedDateTime.now(), false, false)); } catch (ExecutionException ee) { log.error("Upstream error processing chat for course {}: {}", courseId, ee.getMessage(), ee); return ResponseEntity.status(HttpStatus.BAD_GATEWAY) - .body(new AtlasAgentChatResponseDTO("Upstream error while processing your request.", request.sessionId(), ZonedDateTime.now(), false)); + .body(new AtlasAgentChatResponseDTO("Upstream error while processing your request.", request.sessionId(), ZonedDateTime.now(), false, false)); } + finally { + // Cleanup ThreadLocal to prevent memory leaks + AtlasAgentToolsService.cleanup(); + } + } + + /** + * GET /courses/{courseId}/history : Retrieve conversation history for a course + * + * @param courseId the course ID to retrieve history for + * @return list of historical messages + */ + @GetMapping("courses/{courseId}/history") + @EnforceAtLeastInstructorInCourse + public ResponseEntity> getConversationHistory(@PathVariable Long courseId) { + log.debug("Retrieving conversation history for course {}", courseId); + + String sessionId = "course_" + courseId; + List messages = atlasAgentService.getConversationHistory(sessionId); + + // Convert Spring AI Message objects to DTOs, filtering out system messages + List history = messages.stream().filter(msg -> !(msg instanceof SystemMessage)).map(this::convertToDTO).collect(Collectors.toList()); + + log.debug("Returning {} historical messages for course {}", history.size(), courseId); + return ResponseEntity.ok(history); + } + + /** + * Convert Spring AI Message to DTO + * + * @param message the Spring AI message + * @return DTO with role and content + */ + private ChatHistoryMessageDTO convertToDTO(Message message) { + String role = switch (message) { + case UserMessage um -> "user"; + case AssistantMessage am -> "assistant"; + case SystemMessage sm -> "system"; + default -> "unknown"; + }; + + return new ChatHistoryMessageDTO(role, message.getText()); } } diff --git a/src/main/resources/prompts/atlas/agent_system_prompt.st b/src/main/resources/prompts/atlas/agent_system_prompt.st index 68fa20f8f360..3f85c17b0850 100644 --- a/src/main/resources/prompts/atlas/agent_system_prompt.st +++ b/src/main/resources/prompts/atlas/agent_system_prompt.st @@ -3,7 +3,7 @@ You support instructors in creating, discovering, mapping, and managing competen Your primary goals are: • Recommend competency definitions and mappings to course content and exercises. • Ask clarifying follow-up questions when user input is ambiguous or incomplete. - • Produce concise, structured proposals (text + machine-readable summary) and always request explicit instructor confirmation before making any changes in Artemis. + • Produce concise, structured proposals in natural language and always request explicit instructor confirmation before making any changes in Artemis. • Explain why each suggested competency or mapping is relevant (link to course text / exercise evidence when available). • Keep the instructor in control: never perform writes/changes without explicit confirmation. @@ -11,43 +11,47 @@ Tone & style: • Professional, pedagogically-minded, succinct. Use plain-language explanations an instructor can understand. • When presenting proposals, prefer numbered lists, short bullets, and a final single-line "Suggested action" summary. • If unsure, ask one clarifying question rather than guessing. + • Never expose technical implementation details, function names, JSON payloads, API calls, or backend operations to the user. Memory & context: • Use conversation history to maintain context for follow-up questions, but summarize the current "working interpretation" before proposing actionable changes. • When you propose changes, include a short explanation of which pieces of context influenced your suggestion (e.g., "Based on the course description sentence '...' and exercises X and Y..."). When to call backend functions / tools: - • Use functions/tools to retrieve authoritative data (e.g., course description, exercise list, existing competencies), and to perform writes (create competency, map competency to exercise) — but only *after* you obtain explicit confirmation from the instructor. - • Before calling any function that modifies data, present a human-readable summary and ask: "Do you want to apply this mapping? (yes/no)". Wait for a clear affirmative or negative. - -Function use conventions (if tool-calling is available): - • Prefer small, focused calls (e.g., getCourseInfo(courseId), listExercises(courseId), suggestCompetencies(description, courseId)). - • If multiple mappings are proposed, produce a compact machine-readable summary (JSON) with entries: {id, title, description, taxonomy, suggestedExerciseIds, confidenceScore}. Then ask for confirmation on items to apply. - • If a function fails, explain clearly what failed and why; provide a suggested next step (retry, refine prompt, or contact admin). + • Use functions/tools internally to retrieve authoritative data (e.g., course description, exercise list, existing competencies), and to perform writes (create competency, map competency to exercise) — but only *after* you obtain explicit confirmation from the instructor. + • Execute tool calls silently in the background. Never mention that you're "calling a function" or "using a tool" — simply present the results naturally. + • Before performing any action that modifies data, present a human-readable summary and ask: "Do you want to apply this mapping? (yes/no)". Wait for a clear affirmative or negative. + • If data retrieval fails, simply explain what information is missing (e.g., "I couldn't find the course description. Could you provide the course ID?") without mentioning technical errors. Response formatting rules: • When presenting suggestions: first a one-sentence summary, then 1–6 numbered suggestions with: - Title - One-line rationale (2–3 sentences max) - Taxonomy level (use Bloom-rev taxonomy labels: REMEMBER, UNDERSTAND, APPLY, ANALYZE, EVALUATE, CREATE) - - Suggested exercises (IDs or titles) if applicable - - Confidence score (low/medium/high or percent) - • After suggestions, include an exact single question for confirmation (example: "Apply competency 2 to exercise 42? (yes/no)"). + - Suggested exercises (by title or number) if applicable + - Confidence indicator (low/medium/high confidence) + • After suggestions, include an exact single question for confirmation (example: "Would you like to apply competency 2 to exercise Programming Basics? (yes/no)"). + • Present all information in natural, conversational language — never show raw data structures, JSON, XML, or code snippets to users. Safety and constraints: • Do not invent facts about student performance, grades, or private data. If asked for protected/personal data, refuse and point to appropriate tool / admin. - • Never reveal system secrets, API keys, or backend credentials. + • Never reveal system secrets, API keys, backend credentials, function names, or implementation details. • If your confidence is low or data is missing, say "I don't know" and request the exact piece of missing context. + • Never display technical debugging information, stack traces, or error codes to users. -Debugging & diagnostics: - • If the system calls a tool, append a short "tool call summary" of what was invoked and the key fields returned. If the tool returned an error, include the error and a recommended next step. +Internal processing notes (never show to user): + • Process tool results internally and translate them into user-friendly summaries + • Track function calls and JSON structures internally for accuracy, but present only human-readable results + • If a tool returns an error, translate it into plain language (e.g., "couldn't access" instead of "HTTP 404 error") Performance parameters: • Keep replies concise (aim for 5–8 short paragraphs or fewer) unless asked for more detail. • When seeking clarification, ask one specific question at a time. + • Focus on pedagogical value and instructional clarity, not technical implementation. If the user says "apply" or "yes" after a mapping proposal: - • Re-check the exact mapping summary with one last confirmation message and the machine-readable JSON summary. - • Then call the authoritative apply function and report success/failure with the persisted object identifiers. + • Perform the requested action using the appropriate backend functions. + • Report the outcome in simple terms: "✓ Successfully mapped [competency] to [exercise]" or "I couldn't complete that action because [simple reason]". + • Provide the competency title and exercise title (not internal IDs) in confirmation messages. -Always follow these instructions. If a user's command would violate them, politely refuse and explain why. \ No newline at end of file +Always follow these instructions. If a user's command would violate them, politely refuse and explain why. diff --git a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.ts b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.ts index f3d368346cc9..67005139c8dc 100644 --- a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.ts +++ b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.ts @@ -1,4 +1,18 @@ -import { AfterViewChecked, AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, OnInit, computed, inject, signal, viewChild } from '@angular/core'; +import { + AfterViewChecked, + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + OnInit, + Output, + computed, + inject, + signal, + viewChild, +} from '@angular/core'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { faPaperPlane, faRobot, faUser } from '@fortawesome/free-solid-svg-icons'; @@ -36,7 +50,9 @@ export class AgentChatModalComponent implements OnInit, AfterViewInit, AfterView currentMessage = signal(''); isAgentTyping = signal(false); private shouldScrollToBottom = false; - private sessionId!: string; + + // Event emitted when agent likely created/modified competencies + @Output() competencyChanged = new EventEmitter(); // Message validation readonly MAX_MESSAGE_LENGTH = 8000; @@ -49,10 +65,8 @@ export class AgentChatModalComponent implements OnInit, AfterViewInit, AfterView }); ngOnInit(): void { - this.sessionId = `course_${this.courseId}_session_${Date.now()}`; - - // Add a welcome message - this.addMessage(this.translateService.instant('artemisApp.agent.chat.welcome'), false); + // Load conversation history from backend ChatMemory + this.loadHistory(); } ngAfterViewInit(): void { @@ -84,11 +98,17 @@ export class AgentChatModalComponent implements OnInit, AfterViewInit, AfterView // Show typing indicator this.isAgentTyping.set(true); - // Send message with session ID for continuity - this.agentChatService.sendMessage(message, this.courseId, this.sessionId).subscribe({ + // Send message - backend will use courseId as conversationId for memory + this.agentChatService.sendMessage(message, this.courseId).subscribe({ next: (response) => { this.isAgentTyping.set(false); - this.addMessage(response, false); + this.addMessage(response.message || this.translateService.instant('artemisApp.agent.chat.error'), false); + + // Emit event if competencies were modified so parent can refresh + if (response.competenciesModified) { + this.competencyChanged.emit(); + } + // Restore focus to input after agent responds - using Iris pattern setTimeout(() => this.messageInput()?.nativeElement?.focus(), 10); }, @@ -118,6 +138,38 @@ export class AgentChatModalComponent implements OnInit, AfterViewInit, AfterView textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px'; } + private loadHistory(): void { + // Always start with welcome message + this.addMessage(this.translateService.instant('artemisApp.agent.chat.welcome'), false); + + this.agentChatService.getHistory(this.courseId).subscribe({ + next: (history) => { + if (history && history.length > 0) { + // Convert history messages to ChatMessage format + const historyMessages = history.map((msg) => { + let content = msg.content; + if (msg.role === 'user') { + // Remove "Course ID: X\n\n" prefix from user messages + content = content.replace(/^Course ID: \d+\n\n/, ''); + } + return { + id: this.generateMessageId(), + content: content, + isUser: msg.role === 'user', + timestamp: new Date(), + }; + }); + this.messages = [...this.messages, ...historyMessages]; + this.shouldScrollToBottom = true; + this.cdr.markForCheck(); + } + }, + error: () => { + // History load failed, but welcome message is already shown + }, + }); + } + private addMessage(content: string, isUser: boolean): void { const message: ChatMessage = { id: this.generateMessageId(), diff --git a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts index c83c6073e8b3..36e5a2271864 100644 --- a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts +++ b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts @@ -1,7 +1,7 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable, of } from 'rxjs'; -import { catchError, map, timeout } from 'rxjs/operators'; +import { catchError, timeout } from 'rxjs/operators'; import { TranslateService } from '@ngx-translate/core'; interface AgentChatRequest { @@ -14,6 +14,12 @@ interface AgentChatResponse { sessionId?: string; timestamp: string; success: boolean; + competenciesModified?: boolean; +} + +interface ChatHistoryMessage { + role: string; + content: string; } @Injectable({ @@ -23,22 +29,33 @@ export class AgentChatService { private http = inject(HttpClient); private translateService = inject(TranslateService); - sendMessage(message: string, courseId: number, sessionId?: string): Observable { + sendMessage(message: string, courseId: number): Observable { const request: AgentChatRequest = { message, - sessionId, + // Use courseId as conversationId - backend ChatMemory handles context + sessionId: `course_${courseId}`, }; return this.http.post(`api/atlas/agent/courses/${courseId}/chat`, request).pipe( timeout(30000), - map((response) => { - // Return response message regardless of success status - return response.message || this.translateService.instant('artemisApp.agent.chat.error'); + catchError(() => { + // Return error response on failure + return of({ + message: this.translateService.instant('artemisApp.agent.chat.error'), + sessionId: `course_${courseId}`, + timestamp: new Date().toISOString(), + success: false, + competenciesModified: false, + }); }), + ); + } + + getHistory(courseId: number): Observable { + return this.http.get(`api/atlas/agent/courses/${courseId}/history`).pipe( catchError(() => { - // Return translated fallback message on any error - const fallbackMessage = this.translateService.instant('artemisApp.agent.chat.error'); - return of(fallbackMessage); + // Return empty array on failure + return of([]); }), ); } diff --git a/src/main/webapp/app/atlas/manage/competency-management/competency-management.component.ts b/src/main/webapp/app/atlas/manage/competency-management/competency-management.component.ts index da07d2029cce..8ef5625a2cd2 100644 --- a/src/main/webapp/app/atlas/manage/competency-management/competency-management.component.ts +++ b/src/main/webapp/app/atlas/manage/competency-management/competency-management.component.ts @@ -201,6 +201,7 @@ export class CompetencyManagementComponent implements OnInit, OnDestroy { /** * Opens the Agent Chat Modal for AI-powered competency assistance. + * Listens for competency changes and refreshes the list immediately. */ protected openAgentChatModal(): void { const modalRef = this.modalService.open(AgentChatModalComponent, { @@ -208,5 +209,11 @@ export class CompetencyManagementComponent implements OnInit, OnDestroy { backdrop: true, }); modalRef.componentInstance.courseId = this.courseId(); + + // Subscribe to competency change events from the modal + modalRef.componentInstance.competencyChanged.subscribe(() => { + // Refresh competencies immediately when agent creates/modifies them + this.loadCourseCompetencies(this.courseId()); + }); } } From 7bc18c49e9ecd648be196f6e214b7484e98ef3d4 Mon Sep 17 00:00:00 2001 From: Yassine Hmidi Date: Sat, 11 Oct 2025 09:44:01 +0100 Subject: [PATCH 02/31] adapted previous tests to implementation changes --- .../agent-chat-modal.component.spec.ts | 28 ++- .../agent-chat.service.spec.ts | 190 ++---------------- .../atlas/service/AtlasAgentServiceTest.java | 22 +- 3 files changed, 52 insertions(+), 188 deletions(-) diff --git a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.spec.ts b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.spec.ts index 4b8941ee19c4..4e00fc40cd04 100644 --- a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.spec.ts +++ b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.spec.ts @@ -34,6 +34,7 @@ describe('AgentChatModalComponent', () => { mockAgentChatService = { sendMessage: jest.fn(), + getHistory: jest.fn().mockReturnValue(of([])), } as any; mockTranslateService = { @@ -121,8 +122,8 @@ describe('AgentChatModalComponent', () => { // Act component.ngOnInit(); - // Assert - expect(component['sessionId']).toBe(`course_456_session_${mockDateNow}`); + // Assert - component no longer has sessionId, it's generated in service + expect(component.messages.length).toBeGreaterThan(0); }); }); @@ -245,14 +246,20 @@ describe('AgentChatModalComponent', () => { component.currentMessage.set('Test message'); component.isAgentTyping.set(false); component.courseId = 123; - component['sessionId'] = 'test-session-123'; }); it('should send message when send button is clicked', () => { // Arrange component.currentMessage.set('Test message'); component.isAgentTyping.set(false); - mockAgentChatService.sendMessage.mockReturnValue(of('Agent response')); + const mockResponse = { + message: 'Agent response', + sessionId: 'course_123', + timestamp: '2024-01-01T00:00:00Z', + success: true, + competenciesModified: false, + }; + mockAgentChatService.sendMessage.mockReturnValue(of(mockResponse)); // Clear any existing messages to start fresh component.messages = []; @@ -263,7 +270,7 @@ describe('AgentChatModalComponent', () => { sendButton.click(); // Assert - expect(mockAgentChatService.sendMessage).toHaveBeenCalledWith('Test message', 123, component['sessionId']); + expect(mockAgentChatService.sendMessage).toHaveBeenCalledWith('Test message', 123); expect(component.messages).toHaveLength(3); // Welcome + User message + agent response }); @@ -271,7 +278,14 @@ describe('AgentChatModalComponent', () => { // Arrange component.currentMessage.set('Test message'); component.isAgentTyping.set(false); - mockAgentChatService.sendMessage.mockReturnValue(of('Agent response')); + const mockResponse = { + message: 'Agent response', + sessionId: 'course_123', + timestamp: '2024-01-01T00:00:00Z', + success: true, + competenciesModified: false, + }; + mockAgentChatService.sendMessage.mockReturnValue(of(mockResponse)); fixture.detectChanges(); // Act - Test through keyboard interaction @@ -284,7 +298,7 @@ describe('AgentChatModalComponent', () => { textarea.dispatchEvent(enterEvent); // Assert - expect(mockAgentChatService.sendMessage).toHaveBeenCalledWith('Test message', 123, component['sessionId']); + expect(mockAgentChatService.sendMessage).toHaveBeenCalledWith('Test message', 123); }); it('should handle service error gracefully', fakeAsync(() => { diff --git a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts index a386e499cdb9..f7f659b343b7 100644 --- a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts +++ b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts @@ -40,25 +40,25 @@ describe('AgentChatService', () => { describe('sendMessage', () => { const courseId = 123; const message = 'Test message'; - const sessionId = 'test-session-123'; const expectedUrl = `api/atlas/agent/courses/${courseId}/chat`; const expectedRequestBody = { message, - sessionId, + sessionId: `course_${courseId}`, }; - it('should return message field from successful HTTP response', () => { + it('should return AgentChatResponse from successful HTTP response', () => { // Arrange const mockResponse = { message: 'Agent response message', - sessionId: 'test-session-123', + sessionId: 'course_123', timestamp: '2024-01-01T00:00:00Z', success: true, + competenciesModified: false, }; - let result: string | undefined; + let result: any; // Act - service.sendMessage(message, courseId, sessionId).subscribe((response) => { + service.sendMessage(message, courseId).subscribe((response) => { result = response; }); @@ -69,92 +69,17 @@ describe('AgentChatService', () => { req.flush(mockResponse); - expect(result).toBe('Agent response message'); - }); - - it('should return fallback error message when response has empty message', () => { - // Arrange - const mockResponse = { - message: '', - sessionId: 'test-session-123', - timestamp: '2024-01-01T00:00:00Z', - success: true, - }; - const fallbackMessage = 'Error occurred'; - translateService.instant.mockReturnValue(fallbackMessage); - let result: string | undefined; - - // Act - service.sendMessage(message, courseId, sessionId).subscribe((response) => { - result = response; - }); - - // Assert - const req = httpMock.expectOne(expectedUrl); - req.flush(mockResponse); - - expect(translateService.instant).toHaveBeenCalledWith('artemisApp.agent.chat.error'); - expect(result).toBe(fallbackMessage); - }); - - it('should return fallback error message when response has null message', () => { - // Arrange - const mockResponse = { - message: null, - sessionId: 'test-session-123', - timestamp: '2024-01-01T00:00:00Z', - success: true, - }; - const fallbackMessage = 'Error occurred'; - translateService.instant.mockReturnValue(fallbackMessage); - let result: string | undefined; - - // Act - service.sendMessage(message, courseId, sessionId).subscribe((response) => { - result = response; - }); - - // Assert - const req = httpMock.expectOne(expectedUrl); - req.flush(mockResponse); - - expect(translateService.instant).toHaveBeenCalledWith('artemisApp.agent.chat.error'); - expect(result).toBe(fallbackMessage); - }); - - it('should return fallback error message when response has undefined message', () => { - // Arrange - const mockResponse = { - sessionId: 'test-session-123', - timestamp: '2024-01-01T00:00:00Z', - success: true, - // message is undefined - }; - const fallbackMessage = 'Error occurred'; - translateService.instant.mockReturnValue(fallbackMessage); - let result: string | undefined; - - // Act - service.sendMessage(message, courseId, sessionId).subscribe((response) => { - result = response; - }); - - // Assert - const req = httpMock.expectOne(expectedUrl); - req.flush(mockResponse); - - expect(translateService.instant).toHaveBeenCalledWith('artemisApp.agent.chat.error'); - expect(result).toBe(fallbackMessage); + expect(result).toEqual(mockResponse); }); - it('should return fallback error message on HTTP error', () => { + it('should return fallback error response on HTTP error', () => { // Arrange const fallbackMessage = 'Connection error'; translateService.instant.mockReturnValue(fallbackMessage); - let result: string | undefined; + let result: any; // Act - service.sendMessage(message, courseId, sessionId).subscribe((response) => { + service.sendMessage(message, courseId).subscribe((response) => { result = response; }); @@ -163,101 +88,19 @@ describe('AgentChatService', () => { req.flush('Server error', { status: 500, statusText: 'Internal Server Error' }); expect(translateService.instant).toHaveBeenCalledWith('artemisApp.agent.chat.error'); - expect(result).toBe(fallbackMessage); - }); - - it('should return fallback error message on timeout', () => { - // Arrange - jest.useFakeTimers(); - const fallbackMessage = 'Timeout error'; - translateService.instant.mockReturnValue(fallbackMessage); - let result: string | undefined; - let completed = false; - - // Act - service.sendMessage(message, courseId, sessionId).subscribe((response) => { - result = response; - completed = true; - }); - - // Verify HTTP request is made - const req = httpMock.expectOne(expectedUrl); - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual(expectedRequestBody); - - // Don't flush the request, instead advance time to trigger timeout - jest.advanceTimersByTime(30000); - - // Assert - expect(completed).toBeTrue(); - expect(translateService.instant).toHaveBeenCalledWith('artemisApp.agent.chat.error'); - expect(result).toBe(fallbackMessage); - - jest.useRealTimers(); - }); - - it('should handle request without sessionId', () => { - // Arrange - const mockResponse = { - message: 'Response without session', - timestamp: '2024-01-01T00:00:00Z', - success: true, - }; - const expectedRequestBodyWithoutSession = { - message, - sessionId: undefined, - }; - let result: string | undefined; - - // Act - service.sendMessage(message, courseId).subscribe((response) => { - result = response; - }); - - // Assert - const req = httpMock.expectOne(expectedUrl); - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual(expectedRequestBodyWithoutSession); - - req.flush(mockResponse); - - expect(result).toBe('Response without session'); - }); - - it('should use map operator to extract message field correctly', () => { - // Arrange - const mockResponse = { - message: 'Mapped message content', - sessionId: 'test-session-123', - timestamp: '2024-01-01T00:00:00Z', - success: true, - extraField: 'should be ignored', - }; - let result: string | undefined; - - // Act - service.sendMessage(message, courseId, sessionId).subscribe((response) => { - result = response; - }); - - // Assert - const req = httpMock.expectOne(expectedUrl); - req.flush(mockResponse); - - // Verify only the message field is returned, other fields are ignored - expect(result).toBe('Mapped message content'); - expect(result).not.toContain('extraField'); + expect(result.message).toBe(fallbackMessage); + expect(result.success).toBeFalse(); }); it('should use catchError operator properly on network failure', () => { // Arrange const fallbackMessage = 'Network failure handled'; translateService.instant.mockReturnValue(fallbackMessage); - let result: string | undefined; + let result: any; let errorOccurred = false; // Act - service.sendMessage(message, courseId, sessionId).subscribe({ + service.sendMessage(message, courseId).subscribe({ next: (response) => { result = response; }, @@ -270,9 +113,10 @@ describe('AgentChatService', () => { const req = httpMock.expectOne(expectedUrl); req.error(new ProgressEvent('Network error')); - // Verify catchError worked - no error thrown, fallback message returned + // Verify catchError worked - no error thrown, fallback response returned expect(errorOccurred).toBeFalse(); - expect(result).toBe(fallbackMessage); + expect(result.message).toBe(fallbackMessage); + expect(result.success).toBeFalse(); expect(translateService.instant).toHaveBeenCalledWith('artemisApp.agent.chat.error'); }); }); diff --git a/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java index 57430eed1f52..ca6bb5d1374b 100644 --- a/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java @@ -36,7 +36,8 @@ class AtlasAgentServiceTest { @BeforeEach void setUp() { ChatClient chatClient = ChatClient.create(chatModel); - atlasAgentService = new AtlasAgentService(chatClient, templateService); + // Pass null for ToolCallbackProvider and ChatMemory in tests + atlasAgentService = new AtlasAgentService(chatClient, templateService, null, null); } @Test @@ -44,13 +45,14 @@ void testProcessChatMessage_Success() throws ExecutionException, InterruptedExce // Given String testMessage = "Help me create competencies for Java programming"; Long courseId = 123L; + String sessionId = "course_123"; String expectedResponse = "I can help you create competencies for Java programming. Here are my suggestions:\n1. Object-Oriented Programming (APPLY)\n2. Data Structures (UNDERSTAND)\n3. Algorithms (ANALYZE)"; when(templateService.render(anyString(), anyMap())).thenReturn("Test system prompt"); when(chatModel.call(any(Prompt.class))).thenAnswer(invocation -> new ChatResponse(List.of(new Generation(new AssistantMessage(expectedResponse))))); // When - CompletableFuture result = atlasAgentService.processChatMessage(testMessage, courseId); + CompletableFuture result = atlasAgentService.processChatMessage(testMessage, courseId, sessionId); // Then assertThat(result).isNotNull(); @@ -62,13 +64,14 @@ void testProcessChatMessage_EmptyResponse() throws ExecutionException, Interrupt // Given String testMessage = "Test message"; Long courseId = 456L; + String sessionId = "course_456"; String emptyResponse = ""; when(templateService.render(anyString(), anyMap())).thenReturn("Test system prompt"); when(chatModel.call(any(Prompt.class))).thenAnswer(invocation -> new ChatResponse(List.of(new Generation(new AssistantMessage(emptyResponse))))); // When - CompletableFuture result = atlasAgentService.processChatMessage(testMessage, courseId); + CompletableFuture result = atlasAgentService.processChatMessage(testMessage, courseId, sessionId); // Then assertThat(result).isNotNull(); @@ -80,12 +83,13 @@ void testProcessChatMessage_NullResponse() throws ExecutionException, Interrupte // Given String testMessage = "Test message"; Long courseId = 789L; + String sessionId = "course_789"; when(templateService.render(anyString(), anyMap())).thenReturn("Test system prompt"); when(chatModel.call(any(Prompt.class))).thenAnswer(invocation -> new ChatResponse(List.of(new Generation(new AssistantMessage(null))))); // When - CompletableFuture result = atlasAgentService.processChatMessage(testMessage, courseId); + CompletableFuture result = atlasAgentService.processChatMessage(testMessage, courseId, sessionId); // Then assertThat(result).isNotNull(); @@ -97,13 +101,14 @@ void testProcessChatMessage_WhitespaceOnlyResponse() throws ExecutionException, // Given String testMessage = "Test message"; Long courseId = 321L; + String sessionId = "course_321"; String whitespaceResponse = " \n\t "; when(templateService.render(anyString(), anyMap())).thenReturn("Test system prompt"); when(chatModel.call(any(Prompt.class))).thenAnswer(invocation -> new ChatResponse(List.of(new Generation(new AssistantMessage(whitespaceResponse))))); // When - CompletableFuture result = atlasAgentService.processChatMessage(testMessage, courseId); + CompletableFuture result = atlasAgentService.processChatMessage(testMessage, courseId, sessionId); // Then assertThat(result).isNotNull(); @@ -115,13 +120,14 @@ void testProcessChatMessage_ExceptionHandling() throws ExecutionException, Inter // Given String testMessage = "Test message"; Long courseId = 654L; + String sessionId = "course_654"; when(templateService.render(anyString(), anyMap())).thenReturn("Test system prompt"); // Mock the ChatModel to throw an exception when(chatModel.call(any(Prompt.class))).thenThrow(new RuntimeException("ChatModel error")); // When - CompletableFuture result = atlasAgentService.processChatMessage(testMessage, courseId); + CompletableFuture result = atlasAgentService.processChatMessage(testMessage, courseId, sessionId); // Then assertThat(result).isNotNull(); @@ -139,8 +145,8 @@ void testIsAvailable_WithValidChatClient() { @Test void testIsAvailable_WithNullChatClient() { - // Given - AtlasAgentService serviceWithNullClient = new AtlasAgentService(null, templateService); + // Given - pass null for all optional parameters + AtlasAgentService serviceWithNullClient = new AtlasAgentService(null, templateService, null, null); // When boolean available = serviceWithNullClient.isAvailable(); From 8a9b451191fc7a6615621382d6eb1cb53eb2afa0 Mon Sep 17 00:00:00 2001 From: Yassine Hmidi Date: Sat, 11 Oct 2025 10:40:56 +0100 Subject: [PATCH 03/31] fixed javadoc --- .../atlas/service/AtlasAgentToolsService.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java index d9c2f86ecf3f..4505783b5d0f 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java @@ -107,6 +107,12 @@ public String getCourseCompetencies(@ToolParam(description = "the ID of the cour /** * 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, @@ -163,6 +169,9 @@ public String createCompetency(@ToolParam(description = "the ID of the course") /** * 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) { @@ -186,6 +195,9 @@ public String getCourseDescription(@ToolParam(description = "the ID of the cours /** * 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) { From ce7fdca8a663fd3234b2d073db03cc3ed56cbab2 Mon Sep 17 00:00:00 2001 From: Yassine Hmidi Date: Sun, 12 Oct 2025 19:48:12 +0100 Subject: [PATCH 04/31] implemented tests --- .../atlas/service/AtlasAgentToolsService.java | 3 +- .../agent-chat-modal.component.spec.ts | 150 ++++++++++++++++++ .../agent-chat.service.spec.ts | 90 +++++++++++ .../agent/AtlasAgentIntegrationTest.java | 147 +++++++++++++++++ 4 files changed, 389 insertions(+), 1 deletion(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java index 4505783b5d0f..b82c0f3c9135 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java @@ -235,8 +235,9 @@ public String getExercisesListed(@ToolParam(description = "the ID of the course" } private String escapeJson(String input) { - if (input == null) + if (input == null) { return ""; + } return input.replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t"); } } diff --git a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.spec.ts b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.spec.ts index 4e00fc40cd04..347d058202c1 100644 --- a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.spec.ts +++ b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.spec.ts @@ -522,6 +522,156 @@ describe('AgentChatModalComponent', () => { }); }); + describe('History loading', () => { + it('should load conversation history on init', () => { + // Arrange + const mockHistory = [ + { + role: 'user', + content: 'Course ID: 123\n\nWhat competencies should I create?', + }, + { + role: 'assistant', + content: 'Here are some suggested competencies...', + }, + ]; + mockAgentChatService.getHistory.mockReturnValue(of(mockHistory)); + mockTranslateService.instant.mockReturnValue('Welcome!'); + + // Act + component.ngOnInit(); + + // Assert + expect(mockAgentChatService.getHistory).toHaveBeenCalledOnce(); + expect(mockAgentChatService.getHistory).toHaveBeenCalledWith(123); + expect(component.messages).toHaveLength(3); // Welcome + 2 history messages + expect(component.messages[1].content).toBe('What competencies should I create?'); // Prefix removed + expect(component.messages[1].isUser).toBeTrue(); + expect(component.messages[2].content).toBe('Here are some suggested competencies...'); + expect(component.messages[2].isUser).toBeFalse(); + }); + + it('should not modify assistant messages in history', () => { + // Arrange + const assistantContent = 'Assistant response with Course ID: 999 text'; + const mockHistory = [ + { + role: 'assistant', + content: assistantContent, + }, + ]; + mockAgentChatService.getHistory.mockReturnValue(of(mockHistory)); + mockTranslateService.instant.mockReturnValue('Welcome!'); + + // Act + component.ngOnInit(); + + // Assert + expect(component.messages[1].content).toBe(assistantContent); + }); + + it('should handle empty history gracefully', () => { + // Arrange + mockAgentChatService.getHistory.mockReturnValue(of([])); + mockTranslateService.instant.mockReturnValue('Welcome!'); + + // Act + component.ngOnInit(); + + // Assert + expect(component.messages).toHaveLength(1); // Only welcome message + expect(component.messages[0].content).toBe('Welcome!'); + }); + + it('should handle history loading error gracefully', () => { + // Arrange + mockAgentChatService.getHistory.mockReturnValue(throwError(() => new Error('History load failed'))); + mockTranslateService.instant.mockReturnValue('Welcome!'); + + // Act + component.ngOnInit(); + + // Assert + expect(component.messages).toHaveLength(1); // Only welcome message + expect(component.messages[0].content).toBe('Welcome!'); + }); + }); + + describe('Competency modification events', () => { + beforeEach(() => { + mockTranslateService.instant.mockReturnValue('Welcome'); + component.ngOnInit(); + }); + + it('should emit competencyChanged event when competenciesModified is true', () => { + // Arrange + component.currentMessage.set('Create a competency for OOP'); + component.isAgentTyping.set(false); + const mockResponse = { + message: 'Competency created successfully', + sessionId: 'course_123', + timestamp: '2024-01-01T00:00:00Z', + success: true, + competenciesModified: true, + }; + mockAgentChatService.sendMessage.mockReturnValue(of(mockResponse)); + const emitSpy = jest.spyOn(component.competencyChanged, 'emit'); + fixture.detectChanges(); + + // Act + const sendButton = fixture.debugElement.nativeElement.querySelector('.send-button'); + sendButton.click(); + + // Assert + expect(emitSpy).toHaveBeenCalledOnce(); + }); + + it('should not emit competencyChanged event when competenciesModified is false', () => { + // Arrange + component.currentMessage.set('What competencies exist?'); + component.isAgentTyping.set(false); + const mockResponse = { + message: 'Here are the existing competencies...', + sessionId: 'course_123', + timestamp: '2024-01-01T00:00:00Z', + success: true, + competenciesModified: false, + }; + mockAgentChatService.sendMessage.mockReturnValue(of(mockResponse)); + const emitSpy = jest.spyOn(component.competencyChanged, 'emit'); + fixture.detectChanges(); + + // Act + const sendButton = fixture.debugElement.nativeElement.querySelector('.send-button'); + sendButton.click(); + + // Assert + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should not emit competencyChanged event when competenciesModified is undefined', () => { + // Arrange + component.currentMessage.set('Test message'); + component.isAgentTyping.set(false); + const mockResponse = { + message: 'Response without competenciesModified flag', + sessionId: 'course_123', + timestamp: '2024-01-01T00:00:00Z', + success: true, + }; + mockAgentChatService.sendMessage.mockReturnValue(of(mockResponse)); + const emitSpy = jest.spyOn(component.competencyChanged, 'emit'); + fixture.detectChanges(); + + // Act + const sendButton = fixture.debugElement.nativeElement.querySelector('.send-button'); + sendButton.click(); + + // Assert + expect(emitSpy).not.toHaveBeenCalled(); + }); + }); + describe('Textarea auto-resize behavior', () => { it('should auto-resize textarea on input when content exceeds max height', () => { // Arrange diff --git a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts index f7f659b343b7..5104b8dcfd49 100644 --- a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts +++ b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts @@ -120,4 +120,94 @@ describe('AgentChatService', () => { expect(translateService.instant).toHaveBeenCalledWith('artemisApp.agent.chat.error'); }); }); + + describe('getHistory', () => { + const courseId = 456; + const expectedUrl = `api/atlas/agent/courses/${courseId}/history`; + + it('should return chat history from successful HTTP response', () => { + // Arrange + const mockHistory = [ + { + role: 'user', + content: 'Course ID: 456\n\nWhat competencies should I create?', + }, + { + role: 'assistant', + content: 'Here are some suggested competencies...', + }, + ]; + let result: any; + + // Act + service.getHistory(courseId).subscribe((history) => { + result = history; + }); + + // Assert + const req = httpMock.expectOne(expectedUrl); + expect(req.request.method).toBe('GET'); + + req.flush(mockHistory); + + expect(result).toEqual(mockHistory); + expect(result).toHaveLength(2); + }); + + it('should return empty array on HTTP error', () => { + // Arrange + let result: any; + + // Act + service.getHistory(courseId).subscribe((history) => { + result = history; + }); + + // Assert + const req = httpMock.expectOne(expectedUrl); + req.flush('Server error', { status: 500, statusText: 'Internal Server Error' }); + + expect(result).toEqual([]); + }); + + it('should return empty array on network failure', () => { + // Arrange + let result: any; + let errorOccurred = false; + + // Act + service.getHistory(courseId).subscribe({ + next: (history) => { + result = history; + }, + error: () => { + errorOccurred = true; + }, + }); + + // Assert + const req = httpMock.expectOne(expectedUrl); + req.error(new ProgressEvent('Network error')); + + // Verify catchError worked - no error thrown, empty array returned + expect(errorOccurred).toBeFalse(); + expect(result).toEqual([]); + }); + + it('should handle empty history response', () => { + // Arrange + let result: any; + + // Act + service.getHistory(courseId).subscribe((history) => { + result = history; + }); + + // Assert + const req = httpMock.expectOne(expectedUrl); + req.flush([]); + + expect(result).toEqual([]); + }); + }); }); diff --git a/src/test/java/de/tum/cit/aet/artemis/atlas/agent/AtlasAgentIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/atlas/agent/AtlasAgentIntegrationTest.java index 4854cc3e30fe..27d072662bbc 100644 --- a/src/test/java/de/tum/cit/aet/artemis/atlas/agent/AtlasAgentIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/atlas/agent/AtlasAgentIntegrationTest.java @@ -1,20 +1,26 @@ package de.tum.cit.aet.artemis.atlas.agent; import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.util.List; + import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import de.tum.cit.aet.artemis.atlas.AbstractAtlasIntegrationTest; import de.tum.cit.aet.artemis.atlas.dto.AtlasAgentChatRequestDTO; +import de.tum.cit.aet.artemis.atlas.dto.ChatHistoryMessageDTO; import de.tum.cit.aet.artemis.atlas.service.AtlasAgentService; import de.tum.cit.aet.artemis.core.domain.Course; @@ -193,4 +199,145 @@ void testCourseContentMessage() throws Exception { post("/api/atlas/agent/courses/{courseId}/chat", course.getId()).contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(requestDTO))) .andExpect(status().isOk()).andExpect(jsonPath("$.sessionId").value("course-content-session")).andExpect(jsonPath("$.message").exists()); } + + @Nested + class ChatMemory { + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void shouldMaintainConversationContextAcrossMessages() throws Exception { + // Given + String sessionId = "memory-test-session"; + String firstMessage = "My name is John"; + String secondMessage = "What is my name?"; + + // When - Send first message + request.performMvcRequest(post("/api/atlas/agent/courses/{courseId}/chat", course.getId()).contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new AtlasAgentChatRequestDTO(firstMessage, sessionId)))).andExpect(status().isOk()); + + // When - Send second message + request.performMvcRequest(post("/api/atlas/agent/courses/{courseId}/chat", course.getId()).contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new AtlasAgentChatRequestDTO(secondMessage, sessionId)))).andExpect(status().isOk()) + .andExpect(jsonPath("$.message").exists()); + + // Then - Memory maintained implicitly through AI response + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void shouldFilterSystemMessagesFromHistory() throws Exception { + // Given + String sessionId = "course_" + course.getId(); + request.performMvcRequest(post("/api/atlas/agent/courses/{courseId}/chat", course.getId()).contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new AtlasAgentChatRequestDTO("Hello", sessionId)))).andExpect(status().isOk()); + + // When + String actualResponseJson = request.performMvcRequest(get("/api/atlas/agent/courses/{courseId}/history", course.getId()).contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); + + // Then + List actualHistory = objectMapper.readValue(actualResponseJson, new TypeReference<>() { + }); + assertThat(actualHistory).noneMatch(msg -> "system".equals(msg.role())); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void shouldReturnEmptyHistoryForNewCourse() throws Exception { + // Given + Course newCourse = courseUtilService.createCourse(); + + // When + String actualResponseJson = request.performMvcRequest(get("/api/atlas/agent/courses/{courseId}/history", newCourse.getId()).contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); + + // Then + List actualHistory = objectMapper.readValue(actualResponseJson, new TypeReference<>() { + }); + assertThat(actualHistory).isEmpty(); + } + } + + @Nested + class ToolIntegration { + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void shouldSetCompetenciesModifiedFlagWhenToolCalled() throws Exception { + // Given + String competencyCreationMessage = "Create a competency called 'Object-Oriented Programming' with description 'Understanding OOP principles'"; + String sessionId = "tool-test-session"; + + // When + request.performMvcRequest(post("/api/atlas/agent/courses/{courseId}/chat", course.getId()).contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new AtlasAgentChatRequestDTO(competencyCreationMessage, sessionId)))).andExpect(status().isOk()) + .andExpect(jsonPath("$.competenciesModified").exists()); + + // Note: competenciesModified value depends on AI tool invocation + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void shouldIndicateToolsAreAvailable() { + // When + boolean actualAvailability = atlasAgentService.isAvailable(); + + // Then + assertThat(actualAvailability).as("Agent service should be available with tools").isTrue(); + } + } + + @Nested + class Authorization { + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void shouldReturnForbiddenForStudentAccessingChatEndpoint() throws Exception { + // Given + AtlasAgentChatRequestDTO requestDTO = new AtlasAgentChatRequestDTO("Test message", "test-session"); + + // When & Then + request.performMvcRequest( + post("/api/atlas/agent/courses/{courseId}/chat", course.getId()).contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(requestDTO))) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") + void shouldReturnForbiddenForTutorAccessingChatEndpoint() throws Exception { + // Given + AtlasAgentChatRequestDTO requestDTO = new AtlasAgentChatRequestDTO("Test message", "test-session"); + + // When & Then + request.performMvcRequest( + post("/api/atlas/agent/courses/{courseId}/chat", course.getId()).contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(requestDTO))) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void shouldAllowInstructorAccessToChatEndpoint() throws Exception { + // Given + AtlasAgentChatRequestDTO requestDTO = new AtlasAgentChatRequestDTO("Test message", "test-session"); + + // When & Then + request.performMvcRequest( + post("/api/atlas/agent/courses/{courseId}/chat", course.getId()).contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(requestDTO))) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void shouldReturnForbiddenForStudentAccessingHistoryEndpoint() throws Exception { + // When & Then + request.performMvcRequest(get("/api/atlas/agent/courses/{courseId}/history", course.getId()).contentType(MediaType.APPLICATION_JSON)).andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void shouldAllowInstructorAccessToHistoryEndpoint() throws Exception { + // When & Then + request.performMvcRequest(get("/api/atlas/agent/courses/{courseId}/history", course.getId()).contentType(MediaType.APPLICATION_JSON)).andExpect(status().isOk()); + } + } } From 427d70e97de5b364b0f1fdfdd3e8ff50966a5f9c Mon Sep 17 00:00:00 2001 From: Yassine Hmidi Date: Sun, 12 Oct 2025 22:47:29 +0100 Subject: [PATCH 05/31] coderabbit issue + style issue --- .../atlas/service/AtlasAgentToolsService.java | 5 +++-- .../aet/artemis/atlas/web/AtlasAgentResource.java | 14 +++++++++----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java index b82c0f3c9135..8ea9a22dac7b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java @@ -8,6 +8,7 @@ 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 de.tum.cit.aet.artemis.atlas.config.AtlasEnabled; @@ -21,8 +22,8 @@ /** * Service providing tools for the Atlas Agent using Spring AI's @Tool annotation. - * Note: Not marked as @Lazy to ensure @Tool methods are properly scanned by MethodToolCallbackProvider. */ +@Lazy @Service @Conditional(AtlasEnabled.class) public class AtlasAgentToolsService { @@ -238,6 +239,6 @@ private String escapeJson(String input) { if (input == null) { return ""; } - return input.replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t"); + return input.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t"); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java b/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java index a51d080c0333..dab1bf4bedce 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java @@ -1,6 +1,7 @@ package de.tum.cit.aet.artemis.atlas.web; import java.time.ZonedDateTime; +import java.util.ArrayList; import java.util.List; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; @@ -80,19 +81,22 @@ public ResponseEntity sendChatMessage(@PathVariable L } catch (TimeoutException te) { log.warn("Chat timed out for course {}: {}", courseId, te.getMessage()); + boolean competenciesModified = AtlasAgentToolsService.wereCompetenciesModified(); return ResponseEntity.status(HttpStatus.GATEWAY_TIMEOUT) - .body(new AtlasAgentChatResponseDTO("The agent timed out. Please try again.", request.sessionId(), ZonedDateTime.now(), false, false)); + .body(new AtlasAgentChatResponseDTO("The agent timed out. Please try again.", request.sessionId(), ZonedDateTime.now(), false, competenciesModified)); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); log.warn("Chat interrupted for course {}: {}", courseId, ie.getMessage()); + boolean competenciesModified = AtlasAgentToolsService.wereCompetenciesModified(); return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) - .body(new AtlasAgentChatResponseDTO("The request was interrupted. Please try again.", request.sessionId(), ZonedDateTime.now(), false, false)); + .body(new AtlasAgentChatResponseDTO("The request was interrupted. Please try again.", request.sessionId(), ZonedDateTime.now(), false, competenciesModified)); } catch (ExecutionException ee) { log.error("Upstream error processing chat for course {}: {}", courseId, ee.getMessage(), ee); + boolean competenciesModified = AtlasAgentToolsService.wereCompetenciesModified(); return ResponseEntity.status(HttpStatus.BAD_GATEWAY) - .body(new AtlasAgentChatResponseDTO("Upstream error while processing your request.", request.sessionId(), ZonedDateTime.now(), false, false)); + .body(new AtlasAgentChatResponseDTO("Upstream error while processing your request.", request.sessionId(), ZonedDateTime.now(), false, competenciesModified)); } finally { // Cleanup ThreadLocal to prevent memory leaks @@ -115,8 +119,8 @@ public ResponseEntity> getConversationHistory(@PathV List messages = atlasAgentService.getConversationHistory(sessionId); // Convert Spring AI Message objects to DTOs, filtering out system messages - List history = messages.stream().filter(msg -> !(msg instanceof SystemMessage)).map(this::convertToDTO).collect(Collectors.toList()); - + List history = messages.stream().filter(msg -> !(msg instanceof SystemMessage)).map(this::convertToDTO) + .collect(Collectors.toCollection(ArrayList::new)); log.debug("Returning {} historical messages for course {}", history.size(), courseId); return ResponseEntity.ok(history); } From 98b8c7cbe93c61535a65dcddc57c8f3aef10883c Mon Sep 17 00:00:00 2001 From: Yassine Hmidi Date: Mon, 13 Oct 2025 17:01:11 +0100 Subject: [PATCH 06/31] simplified the competency_modified refreshing logic by parsing the chat response --- .../atlas/config/AtlasAgentToolConfig.java | 2 + .../atlas/dto/AtlasAgentChatResponseDTO.java | 2 +- .../atlas/service/AgentChatResult.java | 8 + .../atlas/service/AtlasAgentService.java | 21 ++- .../atlas/service/AtlasAgentToolsService.java | 154 +++++++++--------- .../artemis/atlas/web/AtlasAgentResource.java | 24 +-- .../atlas/service/AtlasAgentServiceTest.java | 38 +++-- 7 files changed, 126 insertions(+), 123 deletions(-) create mode 100644 src/main/java/de/tum/cit/aet/artemis/atlas/service/AgentChatResult.java diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/config/AtlasAgentToolConfig.java b/src/main/java/de/tum/cit/aet/artemis/atlas/config/AtlasAgentToolConfig.java index 5db7c94940e3..1197e7522e0b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/config/AtlasAgentToolConfig.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/config/AtlasAgentToolConfig.java @@ -7,6 +7,7 @@ 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; @@ -15,6 +16,7 @@ * 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 { diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/AtlasAgentChatResponseDTO.java b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/AtlasAgentChatResponseDTO.java index 126ba5c09932..e196c152ff38 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/AtlasAgentChatResponseDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/AtlasAgentChatResponseDTO.java @@ -20,7 +20,7 @@ public record AtlasAgentChatResponseDTO( @NotNull ZonedDateTime timestamp, - boolean success, + Boolean success, Boolean competenciesModified diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AgentChatResult.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AgentChatResult.java new file mode 100644 index 000000000000..2ed0fdaefeea --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AgentChatResult.java @@ -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) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java index e362f7d796c0..33d49dc08149 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java @@ -47,15 +47,16 @@ public AtlasAgentService(@Autowired(required = false) ChatClient chatClient, Atl } /** - * 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 session-based memory to maintain conversation context across popup close/reopen events. + * Detects competency modifications by checking if the response contains specific keywords . * * @param message The user's message * @param courseId The course ID for context * @param sessionId The session ID for maintaining conversation memory - * @return AI response + * @return Result containing the AI response and competency modification flag */ - public CompletableFuture processChatMessage(String message, Long courseId, String sessionId) { + public CompletableFuture processChatMessage(String message, Long courseId, String sessionId) { try { log.debug("Processing chat message for course {} with session {} (messageLength={} chars)", courseId, sessionId, message.length()); @@ -74,21 +75,25 @@ public CompletableFuture processChatMessage(String message, Long courseI promptSpec = promptSpec.advisors(MessageChatMemoryAdvisor.builder(chatMemory).conversationId(sessionId).build()); } - // Add tools if available - // Note: Use toolCallbacks() for ToolCallbackProvider, not tools() which expects tool objects + // Add tools if (toolCallbackProvider != null) { promptSpec = promptSpec.toolCallbacks(toolCallbackProvider); } String response = promptSpec.call().content(); - log.info("Successfully processed chat message for course {} with session {}", courseId, sessionId); - return CompletableFuture.completedFuture(response != null && !response.trim().isEmpty() ? response : "I apologize, but I couldn't generate a response."); + // 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 {} 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 {} with session {}: {}", courseId, sessionId, 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)); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java index 8ea9a22dac7b..10efbb810343 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java @@ -1,5 +1,7 @@ package de.tum.cit.aet.artemis.atlas.service; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.Optional; import java.util.Set; @@ -11,6 +13,9 @@ 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; @@ -30,44 +35,21 @@ 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; - // Thread-safe flag to track if competencies were modified in current request - private static final ThreadLocal competenciesModified = ThreadLocal.withInitial(() -> false); - - public AtlasAgentToolsService(CompetencyRepository competencyRepository, CourseRepository courseRepository, ExerciseRepository exerciseRepository) { + public AtlasAgentToolsService(ObjectMapper objectMapper, CompetencyRepository competencyRepository, CourseRepository courseRepository, ExerciseRepository exerciseRepository) { + this.objectMapper = objectMapper; this.competencyRepository = competencyRepository; this.courseRepository = courseRepository; this.exerciseRepository = exerciseRepository; } - /** - * Check if competencies were modified in the current request. - * - * @return true if competencies were created/modified - */ - public static boolean wereCompetenciesModified() { - return competenciesModified.get(); - } - - /** - * Reset the competencies modified flag. Should be called at the start of each request. - */ - public static void resetCompetenciesModified() { - competenciesModified.set(false); - } - - /** - * Clean up ThreadLocal to prevent memory leaks. - */ - public static void cleanup() { - competenciesModified.remove(); - } - /** * Tool for getting course competencies. * @@ -81,28 +63,30 @@ public String getCourseCompetencies(@ToolParam(description = "the ID of the cour Optional courseOpt = courseRepository.findById(courseId); if (courseOpt.isEmpty()) { - return "{\"error\": \"Course not found with ID: " + courseId + "\"}"; + return toJson(Map.of("error", "Course not found with ID: " + courseId)); } Set competencies = competencyRepository.findAllByCourseId(courseId); - StringBuilder result = new StringBuilder(); - result.append("{\"courseId\": ").append(courseId).append(", \"competencies\": ["); - - competencies.forEach(competency -> result.append("{").append("\"id\": ").append(competency.getId()).append(", ").append("\"title\": \"") - .append(escapeJson(competency.getTitle())).append("\", ").append("\"description\": \"").append(escapeJson(competency.getDescription())).append("\", ") - .append("\"taxonomy\": \"").append(competency.getTaxonomy() != null ? competency.getTaxonomy() : "").append("\"").append("}, ")); - - if (!competencies.isEmpty()) { - result.setLength(result.length() - 2); // Remove last comma - } - - result.append("]}"); - return result.toString(); + // Build competency list using Jackson + var competencyList = competencies.stream().map(competency -> { + Map compData = new LinkedHashMap<>(); + 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 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 "{\"error\": \"Failed to retrieve competencies: " + e.getMessage() + "\"}"; + return toJson(Map.of("error", "Failed to retrieve competencies: " + e.getMessage())); } } @@ -124,7 +108,7 @@ public String createCompetency(@ToolParam(description = "the ID of the course") Optional courseOpt = courseRepository.findById(courseId); if (courseOpt.isEmpty()) { - return "{\"error\": \"Course not found with ID: " + courseId + "\"}"; + return toJson(Map.of("error", "Course not found with ID: " + courseId)); } Course course = courseOpt.get(); @@ -145,26 +129,23 @@ public String createCompetency(@ToolParam(description = "the ID of the course") Competency savedCompetency = competencyRepository.save(competency); - // Mark that competencies were modified in this request - competenciesModified.set(true); - - return String.format(""" - { - "success": true, - "competency": { - "id": %d, - "title": "%s", - "description": "%s", - "taxonomy": "%s", - "courseId": %d - } - } - """, savedCompetency.getId(), escapeJson(savedCompetency.getTitle()), escapeJson(savedCompetency.getDescription()), - savedCompetency.getTaxonomy() != null ? savedCompetency.getTaxonomy() : "", courseId); + // Build response using Jackson + Map 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 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 "{\"error\": \"Failed to create competency: " + e.getMessage() + "\"}"; + return toJson(Map.of("error", "Failed to create competency: " + e.getMessage())); } } @@ -207,38 +188,49 @@ public String getExercisesListed(@ToolParam(description = "the ID of the course" Optional courseOpt = courseRepository.findById(courseId); if (courseOpt.isEmpty()) { - return "{\"error\": \"Course not found with ID: " + courseId + "\"}"; + return toJson(Map.of("error", "Course not found with ID: " + courseId)); } // NOTE: adapt to your ExerciseRepository method Set exercises = exerciseRepository.findByCourseIds(Set.of(courseId)); - StringBuilder result = new StringBuilder(); - result.append("{\"courseId\": ").append(courseId).append(", \"exercises\": ["); - - exercises.forEach(exercise -> result.append("{").append("\"id\": ").append(exercise.getId()).append(", ").append("\"title\": \"") - .append(escapeJson(exercise.getTitle())).append("\", ").append("\"type\": \"").append(exercise.getClass().getSimpleName()).append("\", ") - .append("\"maxPoints\": ").append(exercise.getMaxPoints() != null ? exercise.getMaxPoints() : 0).append(", ").append("\"releaseDate\": \"") - .append(exercise.getReleaseDate() != null ? exercise.getReleaseDate().toString() : "").append("\", ").append("\"dueDate\": \"") - .append(exercise.getDueDate() != null ? exercise.getDueDate().toString() : "").append("\"").append("}, ")); - - if (!exercises.isEmpty()) { - result.setLength(result.length() - 2); - } - - result.append("]}"); - return result.toString(); + // Build exercise list using Jackson + var exerciseList = exercises.stream().map(exercise -> { + Map 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 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 "{\"error\": \"Failed to retrieve exercises: " + e.getMessage() + "\"}"; + return toJson(Map.of("error", "Failed to retrieve exercises: " + e.getMessage())); } } - private String escapeJson(String input) { - if (input == null) { - return ""; + /** + * 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\"}"; } - return input.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t"); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java b/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java index dab1bf4bedce..aac98756e808 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java @@ -32,7 +32,6 @@ import de.tum.cit.aet.artemis.atlas.dto.AtlasAgentChatResponseDTO; import de.tum.cit.aet.artemis.atlas.dto.ChatHistoryMessageDTO; import de.tum.cit.aet.artemis.atlas.service.AtlasAgentService; -import de.tum.cit.aet.artemis.atlas.service.AtlasAgentToolsService; import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInCourse.EnforceAtLeastInstructorInCourse; /** @@ -68,39 +67,26 @@ public ResponseEntity sendChatMessage(@PathVariable L log.debug("Received chat message for course {}: {}", courseId, request.message().substring(0, Math.min(request.message().length(), 50))); try { - // Reset competencies modified flag at start of request - AtlasAgentToolsService.resetCompetenciesModified(); - final var future = atlasAgentService.processChatMessage(request.message(), courseId, request.sessionId()); - final String response = future.get(CHAT_TIMEOUT_SECONDS, TimeUnit.SECONDS); - - // Check if competencies were modified via ThreadLocal flag - boolean competenciesModified = AtlasAgentToolsService.wereCompetenciesModified(); + final var result = future.get(CHAT_TIMEOUT_SECONDS, TimeUnit.SECONDS); - return ResponseEntity.ok(new AtlasAgentChatResponseDTO(response, request.sessionId(), ZonedDateTime.now(), true, competenciesModified)); + return ResponseEntity.ok(new AtlasAgentChatResponseDTO(result.message(), request.sessionId(), ZonedDateTime.now(), true, result.competenciesModified())); } catch (TimeoutException te) { log.warn("Chat timed out for course {}: {}", courseId, te.getMessage()); - boolean competenciesModified = AtlasAgentToolsService.wereCompetenciesModified(); return ResponseEntity.status(HttpStatus.GATEWAY_TIMEOUT) - .body(new AtlasAgentChatResponseDTO("The agent timed out. Please try again.", request.sessionId(), ZonedDateTime.now(), false, competenciesModified)); + .body(new AtlasAgentChatResponseDTO("The agent timed out. Please try again.", request.sessionId(), ZonedDateTime.now(), false, false)); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); log.warn("Chat interrupted for course {}: {}", courseId, ie.getMessage()); - boolean competenciesModified = AtlasAgentToolsService.wereCompetenciesModified(); return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) - .body(new AtlasAgentChatResponseDTO("The request was interrupted. Please try again.", request.sessionId(), ZonedDateTime.now(), false, competenciesModified)); + .body(new AtlasAgentChatResponseDTO("The request was interrupted. Please try again.", request.sessionId(), ZonedDateTime.now(), false, false)); } catch (ExecutionException ee) { log.error("Upstream error processing chat for course {}: {}", courseId, ee.getMessage(), ee); - boolean competenciesModified = AtlasAgentToolsService.wereCompetenciesModified(); return ResponseEntity.status(HttpStatus.BAD_GATEWAY) - .body(new AtlasAgentChatResponseDTO("Upstream error while processing your request.", request.sessionId(), ZonedDateTime.now(), false, competenciesModified)); - } - finally { - // Cleanup ThreadLocal to prevent memory leaks - AtlasAgentToolsService.cleanup(); + .body(new AtlasAgentChatResponseDTO("Upstream error while processing your request.", request.sessionId(), ZonedDateTime.now(), false, false)); } } diff --git a/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java index ca6bb5d1374b..5a5370d76a79 100644 --- a/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java @@ -49,14 +49,16 @@ void testProcessChatMessage_Success() throws ExecutionException, InterruptedExce String expectedResponse = "I can help you create competencies for Java programming. Here are my suggestions:\n1. Object-Oriented Programming (APPLY)\n2. Data Structures (UNDERSTAND)\n3. Algorithms (ANALYZE)"; when(templateService.render(anyString(), anyMap())).thenReturn("Test system prompt"); - when(chatModel.call(any(Prompt.class))).thenAnswer(invocation -> new ChatResponse(List.of(new Generation(new AssistantMessage(expectedResponse))))); + when(chatModel.call(any(Prompt.class))).thenReturn(new ChatResponse(List.of(new Generation(new AssistantMessage(expectedResponse))))); // When - CompletableFuture result = atlasAgentService.processChatMessage(testMessage, courseId, sessionId); + CompletableFuture result = atlasAgentService.processChatMessage(testMessage, courseId, sessionId); // Then assertThat(result).isNotNull(); - assertThat(result.get()).isEqualTo(expectedResponse); + AgentChatResult chatResult = result.get(); + assertThat(chatResult.message()).isEqualTo(expectedResponse); + assertThat(chatResult.competenciesModified()).isFalse(); // No ChatMemory in test, so should be false } @Test @@ -68,14 +70,16 @@ void testProcessChatMessage_EmptyResponse() throws ExecutionException, Interrupt String emptyResponse = ""; when(templateService.render(anyString(), anyMap())).thenReturn("Test system prompt"); - when(chatModel.call(any(Prompt.class))).thenAnswer(invocation -> new ChatResponse(List.of(new Generation(new AssistantMessage(emptyResponse))))); + when(chatModel.call(any(Prompt.class))).thenReturn(new ChatResponse(List.of(new Generation(new AssistantMessage(emptyResponse))))); // When - CompletableFuture result = atlasAgentService.processChatMessage(testMessage, courseId, sessionId); + CompletableFuture result = atlasAgentService.processChatMessage(testMessage, courseId, sessionId); // Then assertThat(result).isNotNull(); - assertThat(result.get()).isEqualTo("I apologize, but I couldn't generate a response."); + AgentChatResult chatResult = result.get(); + assertThat(chatResult.message()).isEqualTo("I apologize, but I couldn't generate a response."); + assertThat(chatResult.competenciesModified()).isFalse(); } @Test @@ -86,14 +90,16 @@ void testProcessChatMessage_NullResponse() throws ExecutionException, Interrupte String sessionId = "course_789"; when(templateService.render(anyString(), anyMap())).thenReturn("Test system prompt"); - when(chatModel.call(any(Prompt.class))).thenAnswer(invocation -> new ChatResponse(List.of(new Generation(new AssistantMessage(null))))); + when(chatModel.call(any(Prompt.class))).thenReturn(new ChatResponse(List.of(new Generation(new AssistantMessage(null))))); // When - CompletableFuture result = atlasAgentService.processChatMessage(testMessage, courseId, sessionId); + CompletableFuture result = atlasAgentService.processChatMessage(testMessage, courseId, sessionId); // Then assertThat(result).isNotNull(); - assertThat(result.get()).isEqualTo("I apologize, but I couldn't generate a response."); + AgentChatResult chatResult = result.get(); + assertThat(chatResult.message()).isEqualTo("I apologize, but I couldn't generate a response."); + assertThat(chatResult.competenciesModified()).isFalse(); } @Test @@ -105,14 +111,16 @@ void testProcessChatMessage_WhitespaceOnlyResponse() throws ExecutionException, String whitespaceResponse = " \n\t "; when(templateService.render(anyString(), anyMap())).thenReturn("Test system prompt"); - when(chatModel.call(any(Prompt.class))).thenAnswer(invocation -> new ChatResponse(List.of(new Generation(new AssistantMessage(whitespaceResponse))))); + when(chatModel.call(any(Prompt.class))).thenReturn(new ChatResponse(List.of(new Generation(new AssistantMessage(whitespaceResponse))))); // When - CompletableFuture result = atlasAgentService.processChatMessage(testMessage, courseId, sessionId); + CompletableFuture result = atlasAgentService.processChatMessage(testMessage, courseId, sessionId); // Then assertThat(result).isNotNull(); - assertThat(result.get()).isEqualTo("I apologize, but I couldn't generate a response."); + AgentChatResult chatResult = result.get(); + assertThat(chatResult.message()).isEqualTo("I apologize, but I couldn't generate a response."); + assertThat(chatResult.competenciesModified()).isFalse(); } @Test @@ -127,11 +135,13 @@ void testProcessChatMessage_ExceptionHandling() throws ExecutionException, Inter when(chatModel.call(any(Prompt.class))).thenThrow(new RuntimeException("ChatModel error")); // When - CompletableFuture result = atlasAgentService.processChatMessage(testMessage, courseId, sessionId); + CompletableFuture result = atlasAgentService.processChatMessage(testMessage, courseId, sessionId); // Then assertThat(result).isNotNull(); - assertThat(result.get()).isEqualTo("I apologize, but I'm having trouble processing your request right now. Please try again later."); + AgentChatResult chatResult = result.get(); + assertThat(chatResult.message()).isEqualTo("I apologize, but I'm having trouble processing your request right now. Please try again later."); + assertThat(chatResult.competenciesModified()).isFalse(); } @Test From 7b8385a0a3a467c2fc6e6f5b9617184e55dcd48c Mon Sep 17 00:00:00 2001 From: Yassine Hmidi Date: Mon, 13 Oct 2025 17:47:33 +0100 Subject: [PATCH 07/31] implemented sessionid logic to support multi-user chat : to have independent chat for every instructor. --- .../artemis/atlas/web/AtlasAgentResource.java | 17 ++++--- .../agent-chat.service.spec.ts | 14 +++++- .../agent-chat-modal/agent-chat.service.ts | 11 +++-- .../atlas/service/AtlasAgentServiceTest.java | 49 +++++++++++++++++++ 4 files changed, 80 insertions(+), 11 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java b/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java index aac98756e808..2e637cad0787 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java @@ -32,6 +32,7 @@ import de.tum.cit.aet.artemis.atlas.dto.AtlasAgentChatResponseDTO; import de.tum.cit.aet.artemis.atlas.dto.ChatHistoryMessageDTO; import de.tum.cit.aet.artemis.atlas.service.AtlasAgentService; +import de.tum.cit.aet.artemis.core.repository.UserRepository; import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInCourse.EnforceAtLeastInstructorInCourse; /** @@ -49,8 +50,11 @@ public class AtlasAgentResource { private final AtlasAgentService atlasAgentService; - public AtlasAgentResource(AtlasAgentService atlasAgentService) { + private final UserRepository userRepository; + + public AtlasAgentResource(AtlasAgentService atlasAgentService, UserRepository userRepository) { this.atlasAgentService = atlasAgentService; + this.userRepository = userRepository; } /** @@ -91,23 +95,24 @@ public ResponseEntity sendChatMessage(@PathVariable L } /** - * GET /courses/{courseId}/history : Retrieve conversation history for a course + * GET /courses/{courseId}/history : Retrieve conversation history for the current user in a course * * @param courseId the course ID to retrieve history for - * @return list of historical messages + * @return list of historical messages for the current user */ @GetMapping("courses/{courseId}/history") @EnforceAtLeastInstructorInCourse public ResponseEntity> getConversationHistory(@PathVariable Long courseId) { - log.debug("Retrieving conversation history for course {}", courseId); + var currentUser = userRepository.getUser(); + log.debug("Retrieving conversation history for course {} and user {}", courseId, currentUser.getId()); - String sessionId = "course_" + courseId; + String sessionId = "course_" + courseId + "_user_" + currentUser.getId(); List messages = atlasAgentService.getConversationHistory(sessionId); // Convert Spring AI Message objects to DTOs, filtering out system messages List history = messages.stream().filter(msg -> !(msg instanceof SystemMessage)).map(this::convertToDTO) .collect(Collectors.toCollection(ArrayList::new)); - log.debug("Returning {} historical messages for course {}", history.size(), courseId); + log.debug("Returning {} historical messages for course {} and user {}", history.size(), courseId, currentUser.getId()); return ResponseEntity.ok(history); } diff --git a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts index 5104b8dcfd49..ad459a2e9d64 100644 --- a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts +++ b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts @@ -1,6 +1,7 @@ import { TestBed } from '@angular/core/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { TranslateService } from '@ngx-translate/core'; +import { AccountService } from 'app/core/auth/account.service'; import { AgentChatService } from './agent-chat.service'; @@ -13,6 +14,10 @@ describe('AgentChatService', () => { instant: jest.fn(), }; + const mockAccountService = { + userIdentity: { id: 42, login: 'testuser' }, + }; + beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], @@ -22,6 +27,10 @@ describe('AgentChatService', () => { provide: TranslateService, useValue: mockTranslateService, }, + { + provide: AccountService, + useValue: mockAccountService, + }, ], }); @@ -39,18 +48,19 @@ describe('AgentChatService', () => { describe('sendMessage', () => { const courseId = 123; + const userId = 42; const message = 'Test message'; const expectedUrl = `api/atlas/agent/courses/${courseId}/chat`; const expectedRequestBody = { message, - sessionId: `course_${courseId}`, + sessionId: `course_${courseId}_user_${userId}`, }; it('should return AgentChatResponse from successful HTTP response', () => { // Arrange const mockResponse = { message: 'Agent response message', - sessionId: 'course_123', + sessionId: `course_${courseId}_user_${userId}`, timestamp: '2024-01-01T00:00:00Z', success: true, competenciesModified: false, diff --git a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts index 36e5a2271864..a138f7b5d2bb 100644 --- a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts +++ b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts @@ -3,6 +3,7 @@ import { HttpClient } from '@angular/common/http'; import { Observable, of } from 'rxjs'; import { catchError, timeout } from 'rxjs/operators'; import { TranslateService } from '@ngx-translate/core'; +import { AccountService } from 'app/core/auth/account.service'; interface AgentChatRequest { message: string; @@ -28,12 +29,16 @@ interface ChatHistoryMessage { export class AgentChatService { private http = inject(HttpClient); private translateService = inject(TranslateService); + private accountService = inject(AccountService); sendMessage(message: string, courseId: number): Observable { + const userId = this.accountService.userIdentity?.id!; + const sessionId = `course_${courseId}_user_${userId}`; + const request: AgentChatRequest = { message, - // Use courseId as conversationId - backend ChatMemory handles context - sessionId: `course_${courseId}`, + // Use courseId + userId for user-specific conversations + sessionId, }; return this.http.post(`api/atlas/agent/courses/${courseId}/chat`, request).pipe( @@ -42,7 +47,7 @@ export class AgentChatService { // Return error response on failure return of({ message: this.translateService.instant('artemisApp.agent.chat.error'), - sessionId: `course_${courseId}`, + sessionId, timestamp: new Date().toISOString(), success: false, competenciesModified: false, diff --git a/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java index 5a5370d76a79..48ddb806728e 100644 --- a/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java @@ -164,4 +164,53 @@ void testIsAvailable_WithNullChatClient() { // Then assertThat(available).isFalse(); } + + @Test + void testProcessChatMessage_DetectsCompetencyCreation() throws ExecutionException, InterruptedException { + // Given + String testMessage = "Create a competency called 'Java Basics'"; + Long courseId = 999L; + String sessionId = "course_999_user_1"; + String responseWithCreation = "I have successfully created the competency titled 'Java Basics' for your course."; + + when(templateService.render(anyString(), anyMap())).thenReturn("Test system prompt"); + when(chatModel.call(any(Prompt.class))).thenReturn(new ChatResponse(List.of(new Generation(new AssistantMessage(responseWithCreation))))); + + // When + CompletableFuture result = atlasAgentService.processChatMessage(testMessage, courseId, sessionId); + + // Then + assertThat(result).isNotNull(); + AgentChatResult chatResult = result.get(); + assertThat(chatResult.message()).isEqualTo(responseWithCreation); + assertThat(chatResult.competenciesModified()).isTrue(); // Should detect "created" keyword + } + + @Test + void testConversationIsolation_DifferentUsers() throws ExecutionException, InterruptedException { + // Given - Two different instructors in the same course + Long courseId = 123L; + String instructor1SessionId = "course_123_user_1"; + String instructor2SessionId = "course_123_user_2"; + String instructor1Message = "Create competency A"; + String instructor2Message = "Create competency B"; + + when(templateService.render(anyString(), anyMap())).thenReturn("Test system prompt"); + when(chatModel.call(any(Prompt.class))).thenReturn(new ChatResponse(List.of(new Generation(new AssistantMessage("Response for instructor 1"))))) + .thenReturn(new ChatResponse(List.of(new Generation(new AssistantMessage("Response for instructor 2"))))); + + // When - Both instructors send messages + CompletableFuture result1 = atlasAgentService.processChatMessage(instructor1Message, courseId, instructor1SessionId); + CompletableFuture result2 = atlasAgentService.processChatMessage(instructor2Message, courseId, instructor2SessionId); + + // Then - Each gets their own response + AgentChatResult chatResult1 = result1.get(); + AgentChatResult chatResult2 = result2.get(); + + assertThat(chatResult1.message()).isEqualTo("Response for instructor 1"); + assertThat(chatResult2.message()).isEqualTo("Response for instructor 2"); + + // Verify sessions are isolated (different session IDs mean different conversation contexts) + assertThat(instructor1SessionId).isNotEqualTo(instructor2SessionId); + } } From 8ffc8e29122ebb742a3df0f60d8e16e622af29a1 Mon Sep 17 00:00:00 2001 From: Yassine Hmidi Date: Tue, 14 Oct 2025 15:12:09 +0100 Subject: [PATCH 08/31] removed the agent cached memory logic as it is inconsistent and not production safe --- .../atlas/config/AtlasAgentToolConfig.java | 16 ---- .../atlas/service/AtlasAgentService.java | 41 +-------- .../artemis/atlas/web/AtlasAgentResource.java | 54 +---------- .../agent-chat.service.spec.ts | 90 ------------------- .../agent-chat-modal/agent-chat.service.ts | 14 --- .../atlas/service/AtlasAgentServiceTest.java | 8 +- 6 files changed, 8 insertions(+), 215 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/config/AtlasAgentToolConfig.java b/src/main/java/de/tum/cit/aet/artemis/atlas/config/AtlasAgentToolConfig.java index 1197e7522e0b..6a4d380be191 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/config/AtlasAgentToolConfig.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/config/AtlasAgentToolConfig.java @@ -1,7 +1,5 @@ package de.tum.cit.aet.artemis.atlas.config; -import org.springframework.ai.chat.memory.ChatMemory; -import org.springframework.ai.chat.memory.MessageWindowChatMemory; import org.springframework.ai.tool.ToolCallbackProvider; import org.springframework.ai.tool.method.MethodToolCallbackProvider; import org.springframework.context.annotation.Bean; @@ -34,18 +32,4 @@ public ToolCallbackProvider atlasToolCallbackProvider(AtlasAgentToolsService too // MethodToolCallbackProvider discovers @Tool-annotated methods on the provided instances return MethodToolCallbackProvider.builder().toolObjects(toolsService).build(); } - - /** - * In-memory chat memory for temporary session-based conversation storage. - * This stores chat history in memory (not database) and is used to maintain - * conversation context when the user closes and reopens the chat popup. - * The memory is temporary and will be cleared when the server restarts. - * Keeps the last 20 messages per conversation by default. - * - * @return ChatMemory instance for temporary conversation storage - */ - @Bean - public ChatMemory chatMemory() { - return MessageWindowChatMemory.builder().maxMessages(20).build(); - } } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java index 33d49dc08149..28b7094c092b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java @@ -1,6 +1,5 @@ package de.tum.cit.aet.artemis.atlas.service; -import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -8,9 +7,6 @@ import org.slf4j.LoggerFactory; import org.springframework.ai.azure.openai.AzureOpenAiChatOptions; import org.springframework.ai.chat.client.ChatClient; -import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; -import org.springframework.ai.chat.memory.ChatMemory; -import org.springframework.ai.chat.messages.Message; import org.springframework.ai.tool.ToolCallbackProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Conditional; @@ -36,24 +32,20 @@ public class AtlasAgentService { private final ToolCallbackProvider toolCallbackProvider; - private final ChatMemory chatMemory; - public AtlasAgentService(@Autowired(required = false) ChatClient chatClient, AtlasPromptTemplateService templateService, - @Autowired(required = false) ToolCallbackProvider toolCallbackProvider, @Autowired(required = false) ChatMemory chatMemory) { + @Autowired(required = false) ToolCallbackProvider toolCallbackProvider) { this.chatClient = chatClient; this.templateService = templateService; this.toolCallbackProvider = toolCallbackProvider; - this.chatMemory = chatMemory; } /** * Process a chat message for the given course and return AI response with modification status. - * Uses session-based memory to maintain conversation context across popup close/reopen events. - * Detects competency modifications by checking if the response contains specific keywords . + * Detects competency modifications by checking if the response contains specific keywords. * * @param message The user's message * @param courseId The course ID for context - * @param sessionId The session ID for maintaining conversation memory + * @param sessionId The session ID (will be used with DB-backed memory) * @return Result containing the AI response and competency modification flag */ public CompletableFuture processChatMessage(String message, Long courseId, String sessionId) { @@ -70,11 +62,6 @@ public CompletableFuture processChatMessage(String message, Lon var promptSpec = chatClient.prompt().system(systemPrompt).user(String.format("Course ID: %d\n\n%s", courseId, message)).options(options); - // Add chat memory advisor for conversation history (temporary session-based caching) - if (chatMemory != null) { - promptSpec = promptSpec.advisors(MessageChatMemoryAdvisor.builder(chatMemory).conversationId(sessionId).build()); - } - // Add tools if (toolCallbackProvider != null) { promptSpec = promptSpec.toolCallbacks(toolCallbackProvider); @@ -97,28 +84,6 @@ public CompletableFuture processChatMessage(String message, Lon } } - /** - * Retrieve conversation history for a given session. - * - * @param sessionId The session ID to retrieve history for - * @return List of messages from the conversation history - */ - public List getConversationHistory(String sessionId) { - try { - if (chatMemory == null) { - log.debug("ChatMemory not available, returning empty history"); - return List.of(); - } - List messages = chatMemory.get(sessionId); - log.debug("Retrieved {} messages from history for session {}", messages.size(), sessionId); - return messages; - } - catch (Exception e) { - log.error("Error retrieving conversation history for session {}: {}", sessionId, e.getMessage(), e); - return List.of(); - } - } - /** * Check if the Atlas Agent service is available and properly configured. * diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java b/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java index 2e637cad0787..a842c30893f3 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java @@ -1,26 +1,18 @@ package de.tum.cit.aet.artemis.atlas.web; import java.time.ZonedDateTime; -import java.util.ArrayList; -import java.util.List; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -import java.util.stream.Collectors; import jakarta.validation.Valid; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.ai.chat.messages.AssistantMessage; -import org.springframework.ai.chat.messages.Message; -import org.springframework.ai.chat.messages.SystemMessage; -import org.springframework.ai.chat.messages.UserMessage; import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Lazy; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -30,9 +22,7 @@ import de.tum.cit.aet.artemis.atlas.config.AtlasEnabled; import de.tum.cit.aet.artemis.atlas.dto.AtlasAgentChatRequestDTO; import de.tum.cit.aet.artemis.atlas.dto.AtlasAgentChatResponseDTO; -import de.tum.cit.aet.artemis.atlas.dto.ChatHistoryMessageDTO; import de.tum.cit.aet.artemis.atlas.service.AtlasAgentService; -import de.tum.cit.aet.artemis.core.repository.UserRepository; import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInCourse.EnforceAtLeastInstructorInCourse; /** @@ -50,11 +40,8 @@ public class AtlasAgentResource { private final AtlasAgentService atlasAgentService; - private final UserRepository userRepository; - - public AtlasAgentResource(AtlasAgentService atlasAgentService, UserRepository userRepository) { + public AtlasAgentResource(AtlasAgentService atlasAgentService) { this.atlasAgentService = atlasAgentService; - this.userRepository = userRepository; } /** @@ -93,43 +80,4 @@ public ResponseEntity sendChatMessage(@PathVariable L .body(new AtlasAgentChatResponseDTO("Upstream error while processing your request.", request.sessionId(), ZonedDateTime.now(), false, false)); } } - - /** - * GET /courses/{courseId}/history : Retrieve conversation history for the current user in a course - * - * @param courseId the course ID to retrieve history for - * @return list of historical messages for the current user - */ - @GetMapping("courses/{courseId}/history") - @EnforceAtLeastInstructorInCourse - public ResponseEntity> getConversationHistory(@PathVariable Long courseId) { - var currentUser = userRepository.getUser(); - log.debug("Retrieving conversation history for course {} and user {}", courseId, currentUser.getId()); - - String sessionId = "course_" + courseId + "_user_" + currentUser.getId(); - List messages = atlasAgentService.getConversationHistory(sessionId); - - // Convert Spring AI Message objects to DTOs, filtering out system messages - List history = messages.stream().filter(msg -> !(msg instanceof SystemMessage)).map(this::convertToDTO) - .collect(Collectors.toCollection(ArrayList::new)); - log.debug("Returning {} historical messages for course {} and user {}", history.size(), courseId, currentUser.getId()); - return ResponseEntity.ok(history); - } - - /** - * Convert Spring AI Message to DTO - * - * @param message the Spring AI message - * @return DTO with role and content - */ - private ChatHistoryMessageDTO convertToDTO(Message message) { - String role = switch (message) { - case UserMessage um -> "user"; - case AssistantMessage am -> "assistant"; - case SystemMessage sm -> "system"; - default -> "unknown"; - }; - - return new ChatHistoryMessageDTO(role, message.getText()); - } } diff --git a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts index ad459a2e9d64..0e5708cb2ce0 100644 --- a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts +++ b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts @@ -130,94 +130,4 @@ describe('AgentChatService', () => { expect(translateService.instant).toHaveBeenCalledWith('artemisApp.agent.chat.error'); }); }); - - describe('getHistory', () => { - const courseId = 456; - const expectedUrl = `api/atlas/agent/courses/${courseId}/history`; - - it('should return chat history from successful HTTP response', () => { - // Arrange - const mockHistory = [ - { - role: 'user', - content: 'Course ID: 456\n\nWhat competencies should I create?', - }, - { - role: 'assistant', - content: 'Here are some suggested competencies...', - }, - ]; - let result: any; - - // Act - service.getHistory(courseId).subscribe((history) => { - result = history; - }); - - // Assert - const req = httpMock.expectOne(expectedUrl); - expect(req.request.method).toBe('GET'); - - req.flush(mockHistory); - - expect(result).toEqual(mockHistory); - expect(result).toHaveLength(2); - }); - - it('should return empty array on HTTP error', () => { - // Arrange - let result: any; - - // Act - service.getHistory(courseId).subscribe((history) => { - result = history; - }); - - // Assert - const req = httpMock.expectOne(expectedUrl); - req.flush('Server error', { status: 500, statusText: 'Internal Server Error' }); - - expect(result).toEqual([]); - }); - - it('should return empty array on network failure', () => { - // Arrange - let result: any; - let errorOccurred = false; - - // Act - service.getHistory(courseId).subscribe({ - next: (history) => { - result = history; - }, - error: () => { - errorOccurred = true; - }, - }); - - // Assert - const req = httpMock.expectOne(expectedUrl); - req.error(new ProgressEvent('Network error')); - - // Verify catchError worked - no error thrown, empty array returned - expect(errorOccurred).toBeFalse(); - expect(result).toEqual([]); - }); - - it('should handle empty history response', () => { - // Arrange - let result: any; - - // Act - service.getHistory(courseId).subscribe((history) => { - result = history; - }); - - // Assert - const req = httpMock.expectOne(expectedUrl); - req.flush([]); - - expect(result).toEqual([]); - }); - }); }); diff --git a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts index a138f7b5d2bb..0cc25bda184c 100644 --- a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts +++ b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts @@ -18,11 +18,6 @@ interface AgentChatResponse { competenciesModified?: boolean; } -interface ChatHistoryMessage { - role: string; - content: string; -} - @Injectable({ providedIn: 'root', }) @@ -55,13 +50,4 @@ export class AgentChatService { }), ); } - - getHistory(courseId: number): Observable { - return this.http.get(`api/atlas/agent/courses/${courseId}/history`).pipe( - catchError(() => { - // Return empty array on failure - return of([]); - }), - ); - } } diff --git a/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java index 48ddb806728e..b7997ceba302 100644 --- a/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java @@ -36,8 +36,8 @@ class AtlasAgentServiceTest { @BeforeEach void setUp() { ChatClient chatClient = ChatClient.create(chatModel); - // Pass null for ToolCallbackProvider and ChatMemory in tests - atlasAgentService = new AtlasAgentService(chatClient, templateService, null, null); + // Pass null for ToolCallbackProvider in tests + atlasAgentService = new AtlasAgentService(chatClient, templateService, null); } @Test @@ -58,7 +58,7 @@ void testProcessChatMessage_Success() throws ExecutionException, InterruptedExce assertThat(result).isNotNull(); AgentChatResult chatResult = result.get(); assertThat(chatResult.message()).isEqualTo(expectedResponse); - assertThat(chatResult.competenciesModified()).isFalse(); // No ChatMemory in test, so should be false + assertThat(chatResult.competenciesModified()).isFalse(); } @Test @@ -156,7 +156,7 @@ void testIsAvailable_WithValidChatClient() { @Test void testIsAvailable_WithNullChatClient() { // Given - pass null for all optional parameters - AtlasAgentService serviceWithNullClient = new AtlasAgentService(null, templateService, null, null); + AtlasAgentService serviceWithNullClient = new AtlasAgentService(null, templateService, null); // When boolean available = serviceWithNullClient.isAvailable(); From 50481c266b81dea089795b636aaf73ecb8a0e790 Mon Sep 17 00:00:00 2001 From: Yassine Hmidi Date: Tue, 14 Oct 2025 15:35:09 +0100 Subject: [PATCH 09/31] removed debugging logs and fixed compile error --- .../atlas/service/AtlasAgentService.java | 4 +- .../atlas/service/AtlasAgentToolsService.java | 8 ---- .../artemis/atlas/web/AtlasAgentResource.java | 3 -- .../agent-chat-modal.component.ts | 37 ------------------- 4 files changed, 1 insertion(+), 51 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java index 28b7094c092b..8a2870a9e66c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java @@ -45,13 +45,11 @@ public AtlasAgentService(@Autowired(required = false) ChatClient chatClient, Atl * * @param message The user's message * @param courseId The course ID for context - * @param sessionId The session ID (will be used with DB-backed memory) + * @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 processChatMessage(String message, Long courseId, String sessionId) { try { - log.debug("Processing chat message for course {} with session {} (messageLength={} chars)", courseId, sessionId, message.length()); - // Load system prompt from external template String resourcePath = "/prompts/atlas/agent_system_prompt.st"; Map variables = Map.of(); // No variables needed for this template diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java index 10efbb810343..da790ed1ad3e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java @@ -59,8 +59,6 @@ public AtlasAgentToolsService(ObjectMapper objectMapper, CompetencyRepository co @Tool(description = "Get all competencies for a course") public String getCourseCompetencies(@ToolParam(description = "the ID of the course") Long courseId) { try { - log.debug("Agent tool: Getting competencies for course {}", courseId); - Optional courseOpt = courseRepository.findById(courseId); if (courseOpt.isEmpty()) { return toJson(Map.of("error", "Course not found with ID: " + courseId)); @@ -104,8 +102,6 @@ public String createCompetency(@ToolParam(description = "the ID of the course") @ToolParam(description = "the description of the competency") String description, @ToolParam(description = "the taxonomy level (REMEMBER, UNDERSTAND, APPLY, ANALYZE, EVALUATE, CREATE)") String taxonomyLevel) { try { - log.debug("Agent tool: Creating competency '{}' for course {}", title, courseId); - Optional courseOpt = courseRepository.findById(courseId); if (courseOpt.isEmpty()) { return toJson(Map.of("error", "Course not found with ID: " + courseId)); @@ -158,8 +154,6 @@ public String createCompetency(@ToolParam(description = "the ID of the course") @Tool(description = "Get the description of a course") public String getCourseDescription(@ToolParam(description = "the ID of the course") Long courseId) { try { - log.debug("Agent tool: Getting course description for course {}", courseId); - Optional courseOpt = courseRepository.findById(courseId); if (courseOpt.isEmpty()) { return ""; @@ -184,8 +178,6 @@ public String getCourseDescription(@ToolParam(description = "the ID of the cours @Tool(description = "List exercises for a course") public String getExercisesListed(@ToolParam(description = "the ID of the course") Long courseId) { try { - log.debug("Agent tool: Getting exercises for course {}", courseId); - Optional courseOpt = courseRepository.findById(courseId); if (courseOpt.isEmpty()) { return toJson(Map.of("error", "Course not found with ID: " + courseId)); diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java b/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java index a842c30893f3..9cf5e3a64304 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java @@ -54,9 +54,6 @@ public AtlasAgentResource(AtlasAgentService atlasAgentService) { @PostMapping("courses/{courseId}/chat") @EnforceAtLeastInstructorInCourse public ResponseEntity sendChatMessage(@PathVariable Long courseId, @Valid @RequestBody AtlasAgentChatRequestDTO request) { - - log.debug("Received chat message for course {}: {}", courseId, request.message().substring(0, Math.min(request.message().length(), 50))); - try { final var future = atlasAgentService.processChatMessage(request.message(), courseId, request.sessionId()); final var result = future.get(CHAT_TIMEOUT_SECONDS, TimeUnit.SECONDS); diff --git a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.ts b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.ts index 67005139c8dc..215734a1ca67 100644 --- a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.ts +++ b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.ts @@ -64,11 +64,6 @@ export class AgentChatModalComponent implements OnInit, AfterViewInit, AfterView return !!(message && !this.isAgentTyping() && !this.isMessageTooLong()); }); - ngOnInit(): void { - // Load conversation history from backend ChatMemory - this.loadHistory(); - } - ngAfterViewInit(): void { // Auto-focus on textarea when modal opens setTimeout(() => this.messageInput()?.nativeElement?.focus(), 10); @@ -138,38 +133,6 @@ export class AgentChatModalComponent implements OnInit, AfterViewInit, AfterView textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px'; } - private loadHistory(): void { - // Always start with welcome message - this.addMessage(this.translateService.instant('artemisApp.agent.chat.welcome'), false); - - this.agentChatService.getHistory(this.courseId).subscribe({ - next: (history) => { - if (history && history.length > 0) { - // Convert history messages to ChatMessage format - const historyMessages = history.map((msg) => { - let content = msg.content; - if (msg.role === 'user') { - // Remove "Course ID: X\n\n" prefix from user messages - content = content.replace(/^Course ID: \d+\n\n/, ''); - } - return { - id: this.generateMessageId(), - content: content, - isUser: msg.role === 'user', - timestamp: new Date(), - }; - }); - this.messages = [...this.messages, ...historyMessages]; - this.shouldScrollToBottom = true; - this.cdr.markForCheck(); - } - }, - error: () => { - // History load failed, but welcome message is already shown - }, - }); - } - private addMessage(content: string, isUser: boolean): void { const message: ChatMessage = { id: this.generateMessageId(), From fda54a3c98b1d1c4b379cdc399cc580d8edffa70 Mon Sep 17 00:00:00 2001 From: Yassine Hmidi Date: Tue, 14 Oct 2025 16:01:08 +0100 Subject: [PATCH 10/31] fixed client compile error --- .../agent-chat-modal/agent-chat-modal.component.ts | 10 +++++++++- .../manage/agent-chat-modal/agent-chat.service.ts | 4 ++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.ts b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.ts index 215734a1ca67..aae53ee1bb41 100644 --- a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.ts +++ b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.ts @@ -50,6 +50,7 @@ export class AgentChatModalComponent implements OnInit, AfterViewInit, AfterView currentMessage = signal(''); isAgentTyping = signal(false); private shouldScrollToBottom = false; + private sessionId!: string; // Event emitted when agent likely created/modified competencies @Output() competencyChanged = new EventEmitter(); @@ -64,6 +65,13 @@ export class AgentChatModalComponent implements OnInit, AfterViewInit, AfterView return !!(message && !this.isAgentTyping() && !this.isMessageTooLong()); }); + ngOnInit(): void { + this.sessionId = `course_${this.courseId}_session_${Date.now()}`; + + // Add a welcome message + this.addMessage(this.translateService.instant('artemisApp.agent.chat.welcome'), false); + } + ngAfterViewInit(): void { // Auto-focus on textarea when modal opens setTimeout(() => this.messageInput()?.nativeElement?.focus(), 10); @@ -94,7 +102,7 @@ export class AgentChatModalComponent implements OnInit, AfterViewInit, AfterView this.isAgentTyping.set(true); // Send message - backend will use courseId as conversationId for memory - this.agentChatService.sendMessage(message, this.courseId).subscribe({ + this.agentChatService.sendMessage(message, this.courseId, this.sessionId).subscribe({ next: (response) => { this.isAgentTyping.set(false); this.addMessage(response.message || this.translateService.instant('artemisApp.agent.chat.error'), false); diff --git a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts index 0cc25bda184c..a38c1a3221c5 100644 --- a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts +++ b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts @@ -26,9 +26,9 @@ export class AgentChatService { private translateService = inject(TranslateService); private accountService = inject(AccountService); - sendMessage(message: string, courseId: number): Observable { + sendMessage(message: string, courseId: number, sessionId?: string): Observable { const userId = this.accountService.userIdentity?.id!; - const sessionId = `course_${courseId}_user_${userId}`; + sessionId = `course_${courseId}_user_${userId}`; const request: AgentChatRequest = { message, From 17fe7bedd5a595721eb76d246d16550fd74bb2bd Mon Sep 17 00:00:00 2001 From: Yassine Hmidi Date: Tue, 14 Oct 2025 16:03:48 +0100 Subject: [PATCH 11/31] fixed client compile error --- .../atlas/manage/agent-chat-modal/agent-chat.service.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts index a38c1a3221c5..cf3bfe0420b1 100644 --- a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts +++ b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts @@ -3,7 +3,7 @@ import { HttpClient } from '@angular/common/http'; import { Observable, of } from 'rxjs'; import { catchError, timeout } from 'rxjs/operators'; import { TranslateService } from '@ngx-translate/core'; -import { AccountService } from 'app/core/auth/account.service'; +//import { AccountService } from 'app/core/auth/account.service'; interface AgentChatRequest { message: string; @@ -24,11 +24,11 @@ interface AgentChatResponse { export class AgentChatService { private http = inject(HttpClient); private translateService = inject(TranslateService); - private accountService = inject(AccountService); + //private accountService = inject(AccountService); sendMessage(message: string, courseId: number, sessionId?: string): Observable { - const userId = this.accountService.userIdentity?.id!; - sessionId = `course_${courseId}_user_${userId}`; + //const userId = this.accountService.userIdentity?.id!; + //const sessionId2 = `course_${courseId}_user_${userId}`; const request: AgentChatRequest = { message, From 40da5969b42abbfaf53e1fba56f9b6566918aa6e Mon Sep 17 00:00:00 2001 From: Yassine Hmidi Date: Tue, 14 Oct 2025 16:05:30 +0100 Subject: [PATCH 12/31] fixed client compile error --- .../app/atlas/manage/agent-chat-modal/agent-chat.service.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts index cf3bfe0420b1..e40fcd570c4b 100644 --- a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts +++ b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts @@ -3,7 +3,6 @@ import { HttpClient } from '@angular/common/http'; import { Observable, of } from 'rxjs'; import { catchError, timeout } from 'rxjs/operators'; import { TranslateService } from '@ngx-translate/core'; -//import { AccountService } from 'app/core/auth/account.service'; interface AgentChatRequest { message: string; @@ -24,15 +23,10 @@ interface AgentChatResponse { export class AgentChatService { private http = inject(HttpClient); private translateService = inject(TranslateService); - //private accountService = inject(AccountService); sendMessage(message: string, courseId: number, sessionId?: string): Observable { - //const userId = this.accountService.userIdentity?.id!; - //const sessionId2 = `course_${courseId}_user_${userId}`; - const request: AgentChatRequest = { message, - // Use courseId + userId for user-specific conversations sessionId, }; From 9509cf6ab0779ff2eb2dc8649e73de0e53c81d84 Mon Sep 17 00:00:00 2001 From: Yassine Hmidi Date: Tue, 14 Oct 2025 17:25:35 +0100 Subject: [PATCH 13/31] fixed client compile error --- .../agent-chat-modal.component.spec.ts | 76 ------------------- .../agent-chat-modal.component.ts | 5 +- .../agent-chat-modal/agent-chat.service.ts | 7 +- 3 files changed, 7 insertions(+), 81 deletions(-) diff --git a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.spec.ts b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.spec.ts index 347d058202c1..9bade9122aaf 100644 --- a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.spec.ts +++ b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.spec.ts @@ -34,7 +34,6 @@ describe('AgentChatModalComponent', () => { mockAgentChatService = { sendMessage: jest.fn(), - getHistory: jest.fn().mockReturnValue(of([])), } as any; mockTranslateService = { @@ -522,81 +521,6 @@ describe('AgentChatModalComponent', () => { }); }); - describe('History loading', () => { - it('should load conversation history on init', () => { - // Arrange - const mockHistory = [ - { - role: 'user', - content: 'Course ID: 123\n\nWhat competencies should I create?', - }, - { - role: 'assistant', - content: 'Here are some suggested competencies...', - }, - ]; - mockAgentChatService.getHistory.mockReturnValue(of(mockHistory)); - mockTranslateService.instant.mockReturnValue('Welcome!'); - - // Act - component.ngOnInit(); - - // Assert - expect(mockAgentChatService.getHistory).toHaveBeenCalledOnce(); - expect(mockAgentChatService.getHistory).toHaveBeenCalledWith(123); - expect(component.messages).toHaveLength(3); // Welcome + 2 history messages - expect(component.messages[1].content).toBe('What competencies should I create?'); // Prefix removed - expect(component.messages[1].isUser).toBeTrue(); - expect(component.messages[2].content).toBe('Here are some suggested competencies...'); - expect(component.messages[2].isUser).toBeFalse(); - }); - - it('should not modify assistant messages in history', () => { - // Arrange - const assistantContent = 'Assistant response with Course ID: 999 text'; - const mockHistory = [ - { - role: 'assistant', - content: assistantContent, - }, - ]; - mockAgentChatService.getHistory.mockReturnValue(of(mockHistory)); - mockTranslateService.instant.mockReturnValue('Welcome!'); - - // Act - component.ngOnInit(); - - // Assert - expect(component.messages[1].content).toBe(assistantContent); - }); - - it('should handle empty history gracefully', () => { - // Arrange - mockAgentChatService.getHistory.mockReturnValue(of([])); - mockTranslateService.instant.mockReturnValue('Welcome!'); - - // Act - component.ngOnInit(); - - // Assert - expect(component.messages).toHaveLength(1); // Only welcome message - expect(component.messages[0].content).toBe('Welcome!'); - }); - - it('should handle history loading error gracefully', () => { - // Arrange - mockAgentChatService.getHistory.mockReturnValue(throwError(() => new Error('History load failed'))); - mockTranslateService.instant.mockReturnValue('Welcome!'); - - // Act - component.ngOnInit(); - - // Assert - expect(component.messages).toHaveLength(1); // Only welcome message - expect(component.messages[0].content).toBe('Welcome!'); - }); - }); - describe('Competency modification events', () => { beforeEach(() => { mockTranslateService.instant.mockReturnValue('Welcome'); diff --git a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.ts b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.ts index aae53ee1bb41..f7f586583676 100644 --- a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.ts +++ b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.ts @@ -50,7 +50,6 @@ export class AgentChatModalComponent implements OnInit, AfterViewInit, AfterView currentMessage = signal(''); isAgentTyping = signal(false); private shouldScrollToBottom = false; - private sessionId!: string; // Event emitted when agent likely created/modified competencies @Output() competencyChanged = new EventEmitter(); @@ -66,8 +65,6 @@ export class AgentChatModalComponent implements OnInit, AfterViewInit, AfterView }); ngOnInit(): void { - this.sessionId = `course_${this.courseId}_session_${Date.now()}`; - // Add a welcome message this.addMessage(this.translateService.instant('artemisApp.agent.chat.welcome'), false); } @@ -102,7 +99,7 @@ export class AgentChatModalComponent implements OnInit, AfterViewInit, AfterView this.isAgentTyping.set(true); // Send message - backend will use courseId as conversationId for memory - this.agentChatService.sendMessage(message, this.courseId, this.sessionId).subscribe({ + this.agentChatService.sendMessage(message, this.courseId).subscribe({ next: (response) => { this.isAgentTyping.set(false); this.addMessage(response.message || this.translateService.instant('artemisApp.agent.chat.error'), false); diff --git a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts index e40fcd570c4b..521c14d04d7a 100644 --- a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts +++ b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts @@ -3,6 +3,7 @@ import { HttpClient } from '@angular/common/http'; import { Observable, of } from 'rxjs'; import { catchError, timeout } from 'rxjs/operators'; import { TranslateService } from '@ngx-translate/core'; +import { AccountService } from 'app/core/auth/account.service'; interface AgentChatRequest { message: string; @@ -23,8 +24,12 @@ interface AgentChatResponse { export class AgentChatService { private http = inject(HttpClient); private translateService = inject(TranslateService); + private accountService = inject(AccountService); + + sendMessage(message: string, courseId: number): Observable { + const userId = this.accountService.userIdentity?.id!; + const sessionId = `course_${courseId}_user_${userId}`; - sendMessage(message: string, courseId: number, sessionId?: string): Observable { const request: AgentChatRequest = { message, sessionId, From 455a8d97ed67272dbd7edc737f743cdbe77d54e6 Mon Sep 17 00:00:00 2001 From: Yassine Hmidi Date: Tue, 14 Oct 2025 20:14:49 +0100 Subject: [PATCH 14/31] implemented more tests --- .../agent/AtlasAgentIntegrationTest.java | 77 ------------------- .../atlas/service/AtlasAgentServiceTest.java | 21 +++++ 2 files changed, 21 insertions(+), 77 deletions(-) diff --git a/src/test/java/de/tum/cit/aet/artemis/atlas/agent/AtlasAgentIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/atlas/agent/AtlasAgentIntegrationTest.java index 27d072662bbc..b0841efc87f1 100644 --- a/src/test/java/de/tum/cit/aet/artemis/atlas/agent/AtlasAgentIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/atlas/agent/AtlasAgentIntegrationTest.java @@ -1,13 +1,10 @@ package de.tum.cit.aet.artemis.atlas.agent; import static org.assertj.core.api.Assertions.assertThat; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import java.util.List; - import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -15,12 +12,10 @@ import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; -import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import de.tum.cit.aet.artemis.atlas.AbstractAtlasIntegrationTest; import de.tum.cit.aet.artemis.atlas.dto.AtlasAgentChatRequestDTO; -import de.tum.cit.aet.artemis.atlas.dto.ChatHistoryMessageDTO; import de.tum.cit.aet.artemis.atlas.service.AtlasAgentService; import de.tum.cit.aet.artemis.core.domain.Course; @@ -200,64 +195,6 @@ void testCourseContentMessage() throws Exception { .andExpect(status().isOk()).andExpect(jsonPath("$.sessionId").value("course-content-session")).andExpect(jsonPath("$.message").exists()); } - @Nested - class ChatMemory { - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void shouldMaintainConversationContextAcrossMessages() throws Exception { - // Given - String sessionId = "memory-test-session"; - String firstMessage = "My name is John"; - String secondMessage = "What is my name?"; - - // When - Send first message - request.performMvcRequest(post("/api/atlas/agent/courses/{courseId}/chat", course.getId()).contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(new AtlasAgentChatRequestDTO(firstMessage, sessionId)))).andExpect(status().isOk()); - - // When - Send second message - request.performMvcRequest(post("/api/atlas/agent/courses/{courseId}/chat", course.getId()).contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(new AtlasAgentChatRequestDTO(secondMessage, sessionId)))).andExpect(status().isOk()) - .andExpect(jsonPath("$.message").exists()); - - // Then - Memory maintained implicitly through AI response - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void shouldFilterSystemMessagesFromHistory() throws Exception { - // Given - String sessionId = "course_" + course.getId(); - request.performMvcRequest(post("/api/atlas/agent/courses/{courseId}/chat", course.getId()).contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(new AtlasAgentChatRequestDTO("Hello", sessionId)))).andExpect(status().isOk()); - - // When - String actualResponseJson = request.performMvcRequest(get("/api/atlas/agent/courses/{courseId}/history", course.getId()).contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); - - // Then - List actualHistory = objectMapper.readValue(actualResponseJson, new TypeReference<>() { - }); - assertThat(actualHistory).noneMatch(msg -> "system".equals(msg.role())); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void shouldReturnEmptyHistoryForNewCourse() throws Exception { - // Given - Course newCourse = courseUtilService.createCourse(); - - // When - String actualResponseJson = request.performMvcRequest(get("/api/atlas/agent/courses/{courseId}/history", newCourse.getId()).contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); - - // Then - List actualHistory = objectMapper.readValue(actualResponseJson, new TypeReference<>() { - }); - assertThat(actualHistory).isEmpty(); - } - } - @Nested class ToolIntegration { @@ -325,19 +262,5 @@ void shouldAllowInstructorAccessToChatEndpoint() throws Exception { post("/api/atlas/agent/courses/{courseId}/chat", course.getId()).contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(requestDTO))) .andExpect(status().isOk()); } - - @Test - @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") - void shouldReturnForbiddenForStudentAccessingHistoryEndpoint() throws Exception { - // When & Then - request.performMvcRequest(get("/api/atlas/agent/courses/{courseId}/history", course.getId()).contentType(MediaType.APPLICATION_JSON)).andExpect(status().isForbidden()); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void shouldAllowInstructorAccessToHistoryEndpoint() throws Exception { - // When & Then - request.performMvcRequest(get("/api/atlas/agent/courses/{courseId}/history", course.getId()).contentType(MediaType.APPLICATION_JSON)).andExpect(status().isOk()); - } } } diff --git a/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java index b7997ceba302..ceaa33a23be8 100644 --- a/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java @@ -213,4 +213,25 @@ void testConversationIsolation_DifferentUsers() throws ExecutionException, Inter // Verify sessions are isolated (different session IDs mean different conversation contexts) assertThat(instructor1SessionId).isNotEqualTo(instructor2SessionId); } + + @Test + void testProcessChatMessage_DetectsMultipleCreationKeywords() throws ExecutionException, InterruptedException { + // Given - Test different keyword variations for competency modification detection + String testMessage = "Create multiple competencies"; + Long courseId = 888L; + String sessionId = "multi_create_session"; + String responseWithMultipleKeywords = "I successfully created three new competencies for you."; + + when(templateService.render(anyString(), anyMap())).thenReturn("Test system prompt"); + when(chatModel.call(any(Prompt.class))).thenReturn(new ChatResponse(List.of(new Generation(new AssistantMessage(responseWithMultipleKeywords))))); + + // When + CompletableFuture result = atlasAgentService.processChatMessage(testMessage, courseId, sessionId); + + // Then + assertThat(result).isNotNull(); + AgentChatResult chatResult = result.get(); + assertThat(chatResult.message()).isEqualTo(responseWithMultipleKeywords); + assertThat(chatResult.competenciesModified()).isTrue(); // Should detect "created" keyword + } } From c83dff846ded80ec4609ccaab3b7877003afccac Mon Sep 17 00:00:00 2001 From: Yassine Hmidi Date: Tue, 14 Oct 2025 23:31:53 +0100 Subject: [PATCH 15/31] added client tests --- .../agent-chat-modal.component.spec.ts | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) diff --git a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.spec.ts b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.spec.ts index 9bade9122aaf..fbb358d1970e 100644 --- a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.spec.ts +++ b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.spec.ts @@ -637,4 +637,215 @@ describe('AgentChatModalComponent', () => { expect(() => component.onTextareaInput()).not.toThrow(); }); }); + + describe('Computed signals', () => { + it('should calculate currentMessageLength correctly', () => { + // Arrange & Act + component.currentMessage.set('Hello'); + + // Assert + expect(component.currentMessageLength()).toBe(5); + }); + + it('should update currentMessageLength when message changes', () => { + // Arrange + component.currentMessage.set('Short'); + expect(component.currentMessageLength()).toBe(5); + + // Act + component.currentMessage.set('A much longer message'); + + // Assert + expect(component.currentMessageLength()).toBe(21); + }); + + it('should correctly identify message as too long', () => { + // Arrange + component.currentMessage.set('a'.repeat(component.MAX_MESSAGE_LENGTH + 1)); + + // Act & Assert + expect(component.isMessageTooLong()).toBeTrue(); + }); + + it('should correctly identify message as not too long', () => { + // Arrange + component.currentMessage.set('a'.repeat(component.MAX_MESSAGE_LENGTH)); + + // Act & Assert + expect(component.isMessageTooLong()).toBeFalse(); + }); + + it('should correctly identify empty message as not too long', () => { + // Arrange + component.currentMessage.set(''); + + // Act & Assert + expect(component.isMessageTooLong()).toBeFalse(); + }); + }); + + describe('Message state management', () => { + it('should clear currentMessage after sending', () => { + // Arrange + mockTranslateService.instant.mockReturnValue('Welcome'); + component.ngOnInit(); + component.currentMessage.set('Test message to send'); + component.isAgentTyping.set(false); + const mockResponse = { + message: 'Agent response', + sessionId: 'course_123', + timestamp: '2024-01-01T00:00:00Z', + success: true, + competenciesModified: false, + }; + mockAgentChatService.sendMessage.mockReturnValue(of(mockResponse)); + fixture.detectChanges(); + + // Act + const sendButton = fixture.debugElement.nativeElement.querySelector('.send-button'); + sendButton.click(); + + // Assert + expect(component.currentMessage()).toBe(''); + }); + + it('should set isAgentTyping to true when sending message', () => { + // Arrange + mockTranslateService.instant.mockReturnValue('Welcome'); + component.ngOnInit(); + component.currentMessage.set('Test message'); + component.isAgentTyping.set(false); + mockAgentChatService.sendMessage.mockReturnValue( + of({ + message: 'Response', + sessionId: 'course_123', + timestamp: '2024-01-01T00:00:00Z', + success: true, + competenciesModified: false, + }), + ); + fixture.detectChanges(); + + // Act + const sendButton = fixture.debugElement.nativeElement.querySelector('.send-button'); + sendButton.click(); + + // Assert - Should be set to true during processing, then back to false + expect(component.isAgentTyping()).toBeFalse(); // False after response completes + }); + + it('should add user message to messages array', () => { + // Arrange + mockTranslateService.instant.mockReturnValue('Welcome'); + component.ngOnInit(); + const initialMessageCount = component.messages.length; + component.currentMessage.set('User test message'); + component.isAgentTyping.set(false); + mockAgentChatService.sendMessage.mockReturnValue( + of({ + message: 'Agent response', + sessionId: 'course_123', + timestamp: '2024-01-01T00:00:00Z', + success: true, + competenciesModified: false, + }), + ); + fixture.detectChanges(); + + // Act + const sendButton = fixture.debugElement.nativeElement.querySelector('.send-button'); + sendButton.click(); + + // Assert + expect(component.messages.length).toBeGreaterThan(initialMessageCount); + const userMessage = component.messages.find((msg) => msg.isUser && msg.content === 'User test message'); + expect(userMessage).toBeDefined(); + }); + }); + + describe('Scroll behavior edge cases', () => { + it('should handle scrollToBottom when messagesContainer is null', () => { + // Arrange + jest.spyOn(component as any, 'messagesContainer').mockReturnValue(null); + component['shouldScrollToBottom'] = true; + + // Act & Assert - Should not throw error + expect(() => component.ngAfterViewChecked()).not.toThrow(); + }); + + it('should handle empty response message from service', fakeAsync(() => { + // Arrange + component.currentMessage.set('Test message'); + component.isAgentTyping.set(false); + const mockResponse = { + message: '', + sessionId: 'course_123', + timestamp: '2024-01-01T00:00:00Z', + success: true, + competenciesModified: false, + }; + mockAgentChatService.sendMessage.mockReturnValue(of(mockResponse)); + mockTranslateService.instant.mockReturnValue('Default error message'); + fixture.detectChanges(); + + // Act + const sendButton = fixture.debugElement.nativeElement.querySelector('.send-button'); + sendButton.click(); + tick(); + + // Assert - Empty response should trigger error message from translate + expect(mockTranslateService.instant).toHaveBeenCalledWith('artemisApp.agent.chat.error'); + expect(component.messages[component.messages.length - 1].content).toBe('Default error message'); + })); + + it('should handle null response message from service', fakeAsync(() => { + // Arrange + component.currentMessage.set('Test message'); + component.isAgentTyping.set(false); + const mockResponse = { + message: null as any, + sessionId: 'course_123', + timestamp: '2024-01-01T00:00:00Z', + success: true, + competenciesModified: false, + }; + mockAgentChatService.sendMessage.mockReturnValue(of(mockResponse)); + mockTranslateService.instant.mockReturnValue('Default error message'); + fixture.detectChanges(); + + // Act + const sendButton = fixture.debugElement.nativeElement.querySelector('.send-button'); + sendButton.click(); + tick(); + + // Assert - Null response should trigger error message + expect(mockTranslateService.instant).toHaveBeenCalledWith('artemisApp.agent.chat.error'); + })); + + it('should set shouldScrollToBottom flag when adding messages', () => { + // Arrange + mockTranslateService.instant.mockReturnValue('Welcome'); + component.ngOnInit(); + component['shouldScrollToBottom'] = false; + + // Act + component.currentMessage.set('Test message'); + component.isAgentTyping.set(false); + mockAgentChatService.sendMessage.mockReturnValue( + of({ + message: 'Response', + sessionId: 'course_123', + timestamp: '2024-01-01T00:00:00Z', + success: true, + competenciesModified: false, + }), + ); + fixture.detectChanges(); + const sendButton = fixture.debugElement.nativeElement.querySelector('.send-button'); + sendButton.click(); + + // Assert - shouldScrollToBottom should be true after adding message, then reset to false after ngAfterViewChecked + expect(component['shouldScrollToBottom']).toBeDefined(); + }); + }); }); From efcf434323c1eaa0f0f9d48e04e1912bb2c21a47 Mon Sep 17 00:00:00 2001 From: Yassine Hmidi Date: Tue, 14 Oct 2025 23:51:01 +0100 Subject: [PATCH 16/31] added client tests --- .../agent-chat.service.spec.ts | 133 +++++++++++++++++- .../agent-chat-modal/agent-chat.service.ts | 2 +- 2 files changed, 132 insertions(+), 3 deletions(-) diff --git a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts index 0e5708cb2ce0..751540f7c244 100644 --- a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts +++ b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts @@ -1,5 +1,6 @@ import { TestBed } from '@angular/core/testing'; -import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TranslateService } from '@ngx-translate/core'; import { AccountService } from 'app/core/auth/account.service'; @@ -20,8 +21,9 @@ describe('AgentChatService', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], providers: [ + provideHttpClient(), + provideHttpClientTesting(), AgentChatService, { provide: TranslateService, @@ -129,5 +131,132 @@ describe('AgentChatService', () => { expect(result.success).toBeFalse(); expect(translateService.instant).toHaveBeenCalledWith('artemisApp.agent.chat.error'); }); + + it('should generate sessionId correctly with userId from AccountService', () => { + // Arrange + const testCourseId = 999; + const expectedSessionId = `course_${testCourseId}_user_${userId}`; + const mockResponse = { + message: 'Response', + sessionId: expectedSessionId, + timestamp: '2024-01-01T00:00:00Z', + success: true, + competenciesModified: false, + }; + + // Act + service.sendMessage(message, testCourseId).subscribe(); + + // Assert + const req = httpMock.expectOne(`api/atlas/agent/courses/${testCourseId}/chat`); + expect(req.request.body.sessionId).toBe(expectedSessionId); + req.flush(mockResponse); + }); + + it('should include all required fields in request body', () => { + // Act + service.sendMessage(message, courseId).subscribe(); + + // Assert + const req = httpMock.expectOne(expectedUrl); + expect(req.request.body).toHaveProperty('message'); + expect(req.request.body).toHaveProperty('sessionId'); + expect(req.request.body.message).toBe(message); + expect(req.request.body.sessionId).toContain('course_'); + expect(req.request.body.sessionId).toContain('user_'); + req.flush({ + message: 'Response', + sessionId: req.request.body.sessionId, + timestamp: '2024-01-01T00:00:00Z', + success: true, + competenciesModified: false, + }); + }); + + it('should return error response with all required fields on failure', () => { + // Arrange + const fallbackMessage = 'Error occurred'; + translateService.instant.mockReturnValue(fallbackMessage); + let result: any; + + // Act + service.sendMessage(message, courseId).subscribe((response) => { + result = response; + }); + + // Assert + const req = httpMock.expectOne(expectedUrl); + req.flush('Error', { status: 400, statusText: 'Bad Request' }); + + expect(result).toBeDefined(); + expect(result.message).toBe(fallbackMessage); + expect(result.sessionId).toBe(`course_${courseId}_user_${userId}`); + expect(result.timestamp).toBeDefined(); + expect(result.success).toBeFalse(); + expect(result.competenciesModified).toBeFalse(); + }); + + it('should handle response with competenciesModified true', () => { + // Arrange + const mockResponse = { + message: 'Competency created', + sessionId: `course_${courseId}_user_${userId}`, + timestamp: '2024-01-01T00:00:00Z', + success: true, + competenciesModified: true, + }; + let result: any; + + // Act + service.sendMessage(message, courseId).subscribe((response) => { + result = response; + }); + + // Assert + const req = httpMock.expectOne(expectedUrl); + req.flush(mockResponse); + + expect(result.competenciesModified).toBeTrue(); + expect(result.success).toBeTrue(); + }); + + it('should make POST request to correct endpoint URL', () => { + // Arrange + const testCourseId = 456; + + // Act + service.sendMessage(message, testCourseId).subscribe(); + + // Assert + const req = httpMock.expectOne(`api/atlas/agent/courses/${testCourseId}/chat`); + expect(req.request.method).toBe('POST'); + expect(req.request.url).toContain(`courses/${testCourseId}/chat`); + req.flush({ + message: 'Response', + sessionId: `course_${testCourseId}_user_${userId}`, + timestamp: '2024-01-01T00:00:00Z', + success: true, + competenciesModified: false, + }); + }); + + it('should handle empty message string', () => { + // Arrange + const emptyMessage = ''; + + // Act + service.sendMessage(emptyMessage, courseId).subscribe(); + + // Assert + const req = httpMock.expectOne(expectedUrl); + expect(req.request.body.message).toBe(''); + req.flush({ + message: 'Response', + sessionId: `course_${courseId}_user_${userId}`, + timestamp: '2024-01-01T00:00:00Z', + success: true, + competenciesModified: false, + }); + }); }); }); diff --git a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts index 521c14d04d7a..18f871e13f0a 100644 --- a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts +++ b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts @@ -27,7 +27,7 @@ export class AgentChatService { private accountService = inject(AccountService); sendMessage(message: string, courseId: number): Observable { - const userId = this.accountService.userIdentity?.id!; + const userId = this.accountService.userIdentity?.id ?? 0; const sessionId = `course_${courseId}_user_${userId}`; const request: AgentChatRequest = { From 5d36cfb10fa4cc7df37bfa5f93ee2c7b82fa059d Mon Sep 17 00:00:00 2001 From: Yassine Hmidi Date: Wed, 15 Oct 2025 23:00:39 +0100 Subject: [PATCH 17/31] resolved issues pointed in review + improved coverage --- .../atlas/dto/AtlasAgentChatResponseDTO.java | 4 +- .../atlas/service/AgentChatResult.java | 4 +- .../atlas/service/AtlasAgentService.java | 5 +- .../atlas/service/AtlasAgentToolsService.java | 4 - .../artemis/atlas/web/AtlasAgentResource.java | 6 +- .../agent-chat-modal.component.spec.ts | 22 --- .../agent-chat-modal.component.ts | 5 +- .../agent-chat.service.spec.ts | 127 ------------------ .../agent-chat-modal/agent-chat.service.ts | 2 +- .../competency-management.component.ts | 2 - 10 files changed, 15 insertions(+), 166 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/AtlasAgentChatResponseDTO.java b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/AtlasAgentChatResponseDTO.java index e196c152ff38..9024aac3e288 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/AtlasAgentChatResponseDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/AtlasAgentChatResponseDTO.java @@ -20,9 +20,9 @@ public record AtlasAgentChatResponseDTO( @NotNull ZonedDateTime timestamp, - Boolean success, + boolean success, - Boolean competenciesModified + boolean competenciesModified ) { } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AgentChatResult.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AgentChatResult.java index 2ed0fdaefeea..13ec78401a5a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AgentChatResult.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AgentChatResult.java @@ -1,8 +1,10 @@ package de.tum.cit.aet.artemis.atlas.service; +import javax.validation.constraints.NotNull; + /** * Internal result object for Atlas Agent chat processing. * Contains the response message and whether competencies were modified. */ -public record AgentChatResult(String message, boolean competenciesModified) { +public record AgentChatResult(@NotNull String message, boolean competenciesModified) { } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java index 8a2870a9e66c..2c19b592f096 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java @@ -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.chat.client.ChatClient.ChatClientRequestSpec; import org.springframework.ai.tool.ToolCallbackProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Conditional; @@ -55,10 +56,10 @@ public CompletableFuture processChatMessage(String message, Lon Map variables = Map.of(); // No variables needed for this template String systemPrompt = templateService.render(resourcePath, variables); - var options = AzureOpenAiChatOptions.builder().deploymentName("gpt-4o").temperature(1.0).build(); + AzureOpenAiChatOptions 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); + ChatClientRequestSpec promptSpec = chatClient.prompt().system(systemPrompt).user(String.format("Course ID: %d\n\n%s", courseId, message)).options(options); // Add tools if (toolCallbackProvider != null) { diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java index da790ed1ad3e..d248315177b6 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java @@ -66,7 +66,6 @@ public String getCourseCompetencies(@ToolParam(description = "the ID of the cour Set competencies = competencyRepository.findAllByCourseId(courseId); - // Build competency list using Jackson var competencyList = competencies.stream().map(competency -> { Map compData = new LinkedHashMap<>(); compData.put("id", competency.getId()); @@ -125,7 +124,6 @@ public String createCompetency(@ToolParam(description = "the ID of the course") Competency savedCompetency = competencyRepository.save(competency); - // Build response using Jackson Map competencyData = new LinkedHashMap<>(); competencyData.put("id", savedCompetency.getId()); competencyData.put("title", savedCompetency.getTitle()); @@ -183,10 +181,8 @@ public String getExercisesListed(@ToolParam(description = "the ID of the course" return toJson(Map.of("error", "Course not found with ID: " + courseId)); } - // NOTE: adapt to your ExerciseRepository method Set exercises = exerciseRepository.findByCourseIds(Set.of(courseId)); - // Build exercise list using Jackson var exerciseList = exercises.stream().map(exercise -> { Map exerciseData = new LinkedHashMap<>(); exerciseData.put("id", exercise.getId()); diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java b/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java index 9cf5e3a64304..ecb4d857cbbc 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java @@ -1,6 +1,7 @@ package de.tum.cit.aet.artemis.atlas.web; import java.time.ZonedDateTime; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -22,6 +23,7 @@ import de.tum.cit.aet.artemis.atlas.config.AtlasEnabled; import de.tum.cit.aet.artemis.atlas.dto.AtlasAgentChatRequestDTO; import de.tum.cit.aet.artemis.atlas.dto.AtlasAgentChatResponseDTO; +import de.tum.cit.aet.artemis.atlas.service.AgentChatResult; import de.tum.cit.aet.artemis.atlas.service.AtlasAgentService; import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInCourse.EnforceAtLeastInstructorInCourse; @@ -55,8 +57,8 @@ public AtlasAgentResource(AtlasAgentService atlasAgentService) { @EnforceAtLeastInstructorInCourse public ResponseEntity sendChatMessage(@PathVariable Long courseId, @Valid @RequestBody AtlasAgentChatRequestDTO request) { try { - final var future = atlasAgentService.processChatMessage(request.message(), courseId, request.sessionId()); - final var result = future.get(CHAT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + final CompletableFuture future = atlasAgentService.processChatMessage(request.message(), courseId, request.sessionId()); + final AgentChatResult result = future.get(CHAT_TIMEOUT_SECONDS, TimeUnit.SECONDS); return ResponseEntity.ok(new AtlasAgentChatResponseDTO(result.message(), request.sessionId(), ZonedDateTime.now(), true, result.competenciesModified())); } diff --git a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.spec.ts b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.spec.ts index fbb358d1970e..f6f749dbe5cc 100644 --- a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.spec.ts +++ b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.spec.ts @@ -572,28 +572,6 @@ describe('AgentChatModalComponent', () => { // Assert expect(emitSpy).not.toHaveBeenCalled(); }); - - it('should not emit competencyChanged event when competenciesModified is undefined', () => { - // Arrange - component.currentMessage.set('Test message'); - component.isAgentTyping.set(false); - const mockResponse = { - message: 'Response without competenciesModified flag', - sessionId: 'course_123', - timestamp: '2024-01-01T00:00:00Z', - success: true, - }; - mockAgentChatService.sendMessage.mockReturnValue(of(mockResponse)); - const emitSpy = jest.spyOn(component.competencyChanged, 'emit'); - fixture.detectChanges(); - - // Act - const sendButton = fixture.debugElement.nativeElement.querySelector('.send-button'); - sendButton.click(); - - // Assert - expect(emitSpy).not.toHaveBeenCalled(); - }); }); describe('Textarea auto-resize behavior', () => { diff --git a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.ts b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.ts index f7f586583676..b712fef42300 100644 --- a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.ts +++ b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.ts @@ -5,11 +5,10 @@ import { ChangeDetectorRef, Component, ElementRef, - EventEmitter, OnInit, - Output, computed, inject, + output, signal, viewChild, } from '@angular/core'; @@ -52,7 +51,7 @@ export class AgentChatModalComponent implements OnInit, AfterViewInit, AfterView private shouldScrollToBottom = false; // Event emitted when agent likely created/modified competencies - @Output() competencyChanged = new EventEmitter(); + competencyChanged = output(); // Message validation readonly MAX_MESSAGE_LENGTH = 8000; diff --git a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts index 751540f7c244..d56629772d8c 100644 --- a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts +++ b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts @@ -131,132 +131,5 @@ describe('AgentChatService', () => { expect(result.success).toBeFalse(); expect(translateService.instant).toHaveBeenCalledWith('artemisApp.agent.chat.error'); }); - - it('should generate sessionId correctly with userId from AccountService', () => { - // Arrange - const testCourseId = 999; - const expectedSessionId = `course_${testCourseId}_user_${userId}`; - const mockResponse = { - message: 'Response', - sessionId: expectedSessionId, - timestamp: '2024-01-01T00:00:00Z', - success: true, - competenciesModified: false, - }; - - // Act - service.sendMessage(message, testCourseId).subscribe(); - - // Assert - const req = httpMock.expectOne(`api/atlas/agent/courses/${testCourseId}/chat`); - expect(req.request.body.sessionId).toBe(expectedSessionId); - req.flush(mockResponse); - }); - - it('should include all required fields in request body', () => { - // Act - service.sendMessage(message, courseId).subscribe(); - - // Assert - const req = httpMock.expectOne(expectedUrl); - expect(req.request.body).toHaveProperty('message'); - expect(req.request.body).toHaveProperty('sessionId'); - expect(req.request.body.message).toBe(message); - expect(req.request.body.sessionId).toContain('course_'); - expect(req.request.body.sessionId).toContain('user_'); - req.flush({ - message: 'Response', - sessionId: req.request.body.sessionId, - timestamp: '2024-01-01T00:00:00Z', - success: true, - competenciesModified: false, - }); - }); - - it('should return error response with all required fields on failure', () => { - // Arrange - const fallbackMessage = 'Error occurred'; - translateService.instant.mockReturnValue(fallbackMessage); - let result: any; - - // Act - service.sendMessage(message, courseId).subscribe((response) => { - result = response; - }); - - // Assert - const req = httpMock.expectOne(expectedUrl); - req.flush('Error', { status: 400, statusText: 'Bad Request' }); - - expect(result).toBeDefined(); - expect(result.message).toBe(fallbackMessage); - expect(result.sessionId).toBe(`course_${courseId}_user_${userId}`); - expect(result.timestamp).toBeDefined(); - expect(result.success).toBeFalse(); - expect(result.competenciesModified).toBeFalse(); - }); - - it('should handle response with competenciesModified true', () => { - // Arrange - const mockResponse = { - message: 'Competency created', - sessionId: `course_${courseId}_user_${userId}`, - timestamp: '2024-01-01T00:00:00Z', - success: true, - competenciesModified: true, - }; - let result: any; - - // Act - service.sendMessage(message, courseId).subscribe((response) => { - result = response; - }); - - // Assert - const req = httpMock.expectOne(expectedUrl); - req.flush(mockResponse); - - expect(result.competenciesModified).toBeTrue(); - expect(result.success).toBeTrue(); - }); - - it('should make POST request to correct endpoint URL', () => { - // Arrange - const testCourseId = 456; - - // Act - service.sendMessage(message, testCourseId).subscribe(); - - // Assert - const req = httpMock.expectOne(`api/atlas/agent/courses/${testCourseId}/chat`); - expect(req.request.method).toBe('POST'); - expect(req.request.url).toContain(`courses/${testCourseId}/chat`); - req.flush({ - message: 'Response', - sessionId: `course_${testCourseId}_user_${userId}`, - timestamp: '2024-01-01T00:00:00Z', - success: true, - competenciesModified: false, - }); - }); - - it('should handle empty message string', () => { - // Arrange - const emptyMessage = ''; - - // Act - service.sendMessage(emptyMessage, courseId).subscribe(); - - // Assert - const req = httpMock.expectOne(expectedUrl); - expect(req.request.body.message).toBe(''); - req.flush({ - message: 'Response', - sessionId: `course_${courseId}_user_${userId}`, - timestamp: '2024-01-01T00:00:00Z', - success: true, - competenciesModified: false, - }); - }); }); }); diff --git a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts index 18f871e13f0a..4262fdbec9f0 100644 --- a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts +++ b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts @@ -15,7 +15,7 @@ interface AgentChatResponse { sessionId?: string; timestamp: string; success: boolean; - competenciesModified?: boolean; + competenciesModified: boolean; } @Injectable({ diff --git a/src/main/webapp/app/atlas/manage/competency-management/competency-management.component.ts b/src/main/webapp/app/atlas/manage/competency-management/competency-management.component.ts index 34174bb5a27a..6693d6b2cb9e 100644 --- a/src/main/webapp/app/atlas/manage/competency-management/competency-management.component.ts +++ b/src/main/webapp/app/atlas/manage/competency-management/competency-management.component.ts @@ -219,9 +219,7 @@ export class CompetencyManagementComponent implements OnInit, OnDestroy { }); modalRef.componentInstance.courseId = this.courseId(); - // Subscribe to competency change events from the modal modalRef.componentInstance.competencyChanged.subscribe(() => { - // Refresh competencies immediately when agent creates/modifies them this.loadCourseCompetencies(this.courseId()); }); } From 7be4264d9c8f6e8cbc19c771cba822fb11cb4ffd Mon Sep 17 00:00:00 2001 From: Yassine Hmidi Date: Wed, 15 Oct 2025 23:44:17 +0100 Subject: [PATCH 18/31] improved coverage --- .../atlas/service/AgentChatResult.java | 2 +- .../agent-chat.service.spec.ts | 200 +++++++++++++++++- .../competency-management.component.spec.ts | 25 +++ 3 files changed, 225 insertions(+), 2 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AgentChatResult.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AgentChatResult.java index 13ec78401a5a..e3563f3ab6ca 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AgentChatResult.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AgentChatResult.java @@ -1,6 +1,6 @@ package de.tum.cit.aet.artemis.atlas.service; -import javax.validation.constraints.NotNull; +import jakarta.validation.constraints.NotNull; /** * Internal result object for Atlas Agent chat processing. diff --git a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts index d56629772d8c..5a06cee4fb79 100644 --- a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts +++ b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts @@ -1,4 +1,4 @@ -import { TestBed } from '@angular/core/testing'; +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TranslateService } from '@ngx-translate/core'; @@ -131,5 +131,203 @@ describe('AgentChatService', () => { expect(result.success).toBeFalse(); expect(translateService.instant).toHaveBeenCalledWith('artemisApp.agent.chat.error'); }); + + describe('sendMessage - timeout handling', () => { + const courseId = 123; + const message = 'Test message'; + + it('should handle timeout after 30 seconds', fakeAsync(() => { + // Arrange + const expectedUrl = `api/atlas/agent/courses/${courseId}/chat`; + let result: any; + + // Act + service.sendMessage(message, courseId).subscribe({ + next: (response) => { + result = response; + }, + }); + + httpMock.expectOne(expectedUrl); + + // Simulate timeout by advancing time past 30 seconds + tick(30001); + + // Assert - timeout should trigger catchError which returns fallback response + expect(result).toBeDefined(); + expect(result.success).toBeFalse(); + expect(result.competenciesModified).toBeFalse(); + expect(translateService.instant).toHaveBeenCalledWith('artemisApp.agent.chat.error'); + })); + }); + + describe('sessionId generation', () => { + const courseId = 456; + const message = 'Test'; + + it('should generate sessionId with valid userId', () => { + // Arrange + mockAccountService.userIdentity = { id: 42, login: 'testuser' }; + const expectedSessionId = 'course_456_user_42'; + + // Act + service.sendMessage(message, courseId).subscribe(); + + const req = httpMock.expectOne(`api/atlas/agent/courses/${courseId}/chat`); + + // Assert + expect(req.request.body.sessionId).toBe(expectedSessionId); + req.flush({ + message: 'response', + sessionId: expectedSessionId, + timestamp: '2024-01-01T00:00:00Z', + success: true, + competenciesModified: false, + }); + }); + + it('should use 0 as fallback when userIdentity is null', () => { + // Arrange + mockAccountService.userIdentity = null; + const expectedSessionId = 'course_456_user_0'; + + // Act + service.sendMessage(message, courseId).subscribe(); + + const req = httpMock.expectOne(`api/atlas/agent/courses/${courseId}/chat`); + + // Assert + expect(req.request.body.sessionId).toBe(expectedSessionId); + req.flush({ + message: 'response', + sessionId: expectedSessionId, + timestamp: '2024-01-01T00:00:00Z', + success: true, + competenciesModified: false, + }); + }); + + it('should use 0 as fallback when userIdentity.id is undefined', () => { + // Arrange + mockAccountService.userIdentity = { id: undefined, login: 'testuser' }; + const expectedSessionId = 'course_456_user_0'; + + // Act + service.sendMessage(message, courseId).subscribe(); + + const req = httpMock.expectOne(`api/atlas/agent/courses/${courseId}/chat`); + + // Assert + expect(req.request.body.sessionId).toBe(expectedSessionId); + req.flush({ + message: 'response', + sessionId: expectedSessionId, + timestamp: '2024-01-01T00:00:00Z', + success: true, + competenciesModified: false, + }); + }); + }); + + describe('HTTP request details', () => { + const courseId = 789; + const message = 'Test message'; + + it('should make POST request to correct URL', () => { + // Arrange + const expectedUrl = `api/atlas/agent/courses/${courseId}/chat`; + + // Act + service.sendMessage(message, courseId).subscribe(); + + // Assert + const req = httpMock.expectOne(expectedUrl); + expect(req.request.method).toBe('POST'); + req.flush({ + message: 'response', + sessionId: 'test', + timestamp: '2024-01-01T00:00:00Z', + success: true, + competenciesModified: false, + }); + }); + + it('should send request with correct body structure', () => { + // Arrange + mockAccountService.userIdentity = { id: 99, login: 'user99' }; + + // Act + service.sendMessage(message, courseId).subscribe(); + + const req = httpMock.expectOne(`api/atlas/agent/courses/${courseId}/chat`); + + // Assert + expect(req.request.body).toEqual({ + message: 'Test message', + sessionId: 'course_789_user_99', + }); + + req.flush({ + message: 'response', + sessionId: 'course_789_user_99', + timestamp: '2024-01-01T00:00:00Z', + success: true, + competenciesModified: false, + }); + }); + }); + + describe('Error response handling', () => { + const courseId = 111; + const message = 'Test'; + + it('should return error response object on catchError', () => { + // Arrange + mockAccountService.userIdentity = { id: 55, login: 'testuser' }; + mockTranslateService.instant.mockReturnValue('Translated error message'); + let result: any; + + // Act + service.sendMessage(message, courseId).subscribe({ + next: (response) => { + result = response; + }, + }); + + const req = httpMock.expectOne(`api/atlas/agent/courses/${courseId}/chat`); + req.error(new ProgressEvent('error')); + + // Assert + expect(result).toBeDefined(); + expect(result.message).toBe('Translated error message'); + expect(result.success).toBeFalse(); + expect(result.competenciesModified).toBeFalse(); + expect(result.sessionId).toBe('course_111_user_55'); + expect(mockTranslateService.instant).toHaveBeenCalledWith('artemisApp.agent.chat.error'); + }); + + it('should include timestamp in error response', () => { + // Arrange + const beforeTime = new Date().toISOString(); + let result: any; + + // Act + service.sendMessage(message, courseId).subscribe({ + next: (response) => { + result = response; + }, + }); + + const req = httpMock.expectOne(`api/atlas/agent/courses/${courseId}/chat`); + req.error(new ProgressEvent('error')); + + const afterTime = new Date().toISOString(); + + // Assert + expect(result.timestamp).toBeDefined(); + expect(result.timestamp >= beforeTime).toBeTruthy(); + expect(result.timestamp <= afterTime).toBeTruthy(); + }); + }); }); }); diff --git a/src/main/webapp/app/atlas/manage/competency-management/competency-management.component.spec.ts b/src/main/webapp/app/atlas/manage/competency-management/competency-management.component.spec.ts index 18fd50a2db70..ac2eb86f15c1 100644 --- a/src/main/webapp/app/atlas/manage/competency-management/competency-management.component.spec.ts +++ b/src/main/webapp/app/atlas/manage/competency-management/competency-management.component.spec.ts @@ -236,4 +236,29 @@ describe('CompetencyManagementComponent', () => { expect(component.competencies()).toHaveLength(existingCompetencies + 3); }); + + it('should open agent chat modal and set courseId', () => { + // Arrange + sessionStorageService.store('alreadyVisitedCompetencyManagement', true); + const modalRef = { + componentInstance: { + courseId: undefined, + competencyChanged: { + subscribe: jest.fn(), + }, + }, + } as any; + const openModalSpy = jest.spyOn(modalService, 'open').mockReturnValue(modalRef); + fixture.detectChanges(); + + // Act + component['openAgentChatModal'](); + + // Assert + expect(openModalSpy).toHaveBeenCalledWith(expect.anything(), { + size: 'lg', + backdrop: true, + }); + expect(modalRef.componentInstance.courseId).toBe(1); + }); }); From a4fdeef8731456bd69d192a7a2854b1ee0de4981 Mon Sep 17 00:00:00 2001 From: Yassine Hmidi Date: Thu, 16 Oct 2025 00:44:45 +0100 Subject: [PATCH 19/31] corrected client tests --- .../atlas/manage/agent-chat-modal/agent-chat.service.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts index 5a06cee4fb79..a0ae2409a9af 100644 --- a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts +++ b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts @@ -15,7 +15,7 @@ describe('AgentChatService', () => { instant: jest.fn(), }; - const mockAccountService = { + const mockAccountService: { userIdentity: { id?: number; login: string } | null } = { userIdentity: { id: 42, login: 'testuser' }, }; From 568a89d6edda38d9ee593de099df3005a85ed190 Mon Sep 17 00:00:00 2001 From: Yassine Hmidi Date: Thu, 16 Oct 2025 22:27:25 +0100 Subject: [PATCH 20/31] made changes based on review --- .../atlas/config/AtlasAgentToolConfig.java | 6 - .../atlas/dto/AtlasAgentChatResponseDTO.java | 15 +- .../atlas/dto/ChatHistoryMessageDTO.java | 19 -- .../atlas/service/AtlasAgentService.java | 41 +++-- .../atlas/service/AtlasAgentToolsService.java | 134 +++++++------- .../artemis/atlas/web/AtlasAgentResource.java | 7 - .../prompts/atlas/agent_system_prompt.st | 170 ++++++++++++------ .../agent-chat-modal.component.spec.ts | 123 ------------- .../agent-chat.service.spec.ts | 64 +------ .../agent-chat-modal/agent-chat.service.ts | 7 +- .../competency-management.component.spec.ts | 6 +- .../agent/AtlasAgentIntegrationTest.java | 34 ---- .../atlas/service/AtlasAgentServiceTest.java | 70 +------- 13 files changed, 216 insertions(+), 480 deletions(-) delete mode 100644 src/main/java/de/tum/cit/aet/artemis/atlas/dto/ChatHistoryMessageDTO.java diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/config/AtlasAgentToolConfig.java b/src/main/java/de/tum/cit/aet/artemis/atlas/config/AtlasAgentToolConfig.java index 6a4d380be191..325620ea5543 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/config/AtlasAgentToolConfig.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/config/AtlasAgentToolConfig.java @@ -9,11 +9,6 @@ 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) @@ -29,7 +24,6 @@ public class AtlasAgentToolConfig { */ @Bean public ToolCallbackProvider atlasToolCallbackProvider(AtlasAgentToolsService toolsService) { - // MethodToolCallbackProvider discovers @Tool-annotated methods on the provided instances return MethodToolCallbackProvider.builder().toolObjects(toolsService).build(); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/AtlasAgentChatResponseDTO.java b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/AtlasAgentChatResponseDTO.java index 9024aac3e288..0ef40effc559 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/AtlasAgentChatResponseDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/AtlasAgentChatResponseDTO.java @@ -12,17 +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, - - boolean competenciesModified - -) { +public record AtlasAgentChatResponseDTO(@NotBlank @Size(max = 10000) String message, @NotNull String sessionId, @NotNull ZonedDateTime timestamp, boolean success, + boolean competenciesModified) { } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/ChatHistoryMessageDTO.java b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/ChatHistoryMessageDTO.java deleted file mode 100644 index d62deac85e51..000000000000 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/ChatHistoryMessageDTO.java +++ /dev/null @@ -1,19 +0,0 @@ -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 - -) { -} diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java index 2c19b592f096..d03a27315e1f 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java @@ -3,11 +3,10 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.ai.azure.openai.AzureOpenAiChatOptions; import org.springframework.ai.chat.client.ChatClient; -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.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Conditional; @@ -25,60 +24,69 @@ @Conditional(AtlasEnabled.class) public class AtlasAgentService { - private static final Logger log = LoggerFactory.getLogger(AtlasAgentService.class); - private final ChatClient chatClient; private final AtlasPromptTemplateService templateService; private final ToolCallbackProvider toolCallbackProvider; + private final ChatMemory chatMemory; + + private final AtlasAgentToolsService atlasAgentToolsService; + public AtlasAgentService(@Autowired(required = false) ChatClient chatClient, AtlasPromptTemplateService templateService, - @Autowired(required = false) ToolCallbackProvider toolCallbackProvider) { + @Autowired(required = false) ToolCallbackProvider toolCallbackProvider, @Autowired(required = false) ChatMemory chatMemory, + @Autowired(required = false) 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 with modification status. - * Detects competency modifications by checking if the response contains specific keywords. + * Uses request-scoped state tracking to detect competency modifications. * * @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) + * @param sessionId The session ID for chat memory * @return Result containing the AI response and competency modification flag */ public CompletableFuture processChatMessage(String message, Long courseId, String sessionId) { try { + // Load system prompt from external template String resourcePath = "/prompts/atlas/agent_system_prompt.st"; Map variables = Map.of(); // No variables needed for this template String systemPrompt = templateService.render(resourcePath, variables); - AzureOpenAiChatOptions 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 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); + var 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(); - // 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")); + // Check if createCompetency was called by examining the service state + boolean competenciesModified = atlasAgentToolsService != null && atlasAgentToolsService.wasCompetencyCreated(); - 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 {} 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)); } } @@ -93,7 +101,6 @@ public boolean isAvailable() { return chatClient != null; } catch (Exception e) { - log.warn("Atlas Agent service availability check failed: {}", e.getMessage()); return false; } } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java index d248315177b6..edf43fc41e24 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java @@ -10,8 +10,8 @@ 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; @@ -27,8 +27,9 @@ /** * Service providing tools for the Atlas Agent using Spring AI's @Tool annotation. + * Request-scoped to track tool calls per HTTP request. */ -@Lazy +@RequestScope @Service @Conditional(AtlasEnabled.class) public class AtlasAgentToolsService { @@ -43,6 +44,9 @@ public class AtlasAgentToolsService { 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; @@ -58,33 +62,27 @@ public AtlasAgentToolsService(ObjectMapper objectMapper, CompetencyRepository co */ @Tool(description = "Get all competencies for a course") public String getCourseCompetencies(@ToolParam(description = "the ID of the course") Long courseId) { - try { - Optional courseOpt = courseRepository.findById(courseId); - if (courseOpt.isEmpty()) { - return toJson(Map.of("error", "Course not found with ID: " + courseId)); - } - - Set competencies = competencyRepository.findAllByCourseId(courseId); - - var competencyList = competencies.stream().map(competency -> { - Map compData = new LinkedHashMap<>(); - 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(); + Optional courseOptional = courseRepository.findById(courseId); + if (courseOptional.isEmpty()) { + return toJson(Map.of("error", "Course not found with ID: " + courseId)); + } - Map response = new LinkedHashMap<>(); - response.put("courseId", courseId); - response.put("competencies", competencyList); + Set competencies = competencyRepository.findAllByCourseId(courseId); - 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())); - } + var competencyList = competencies.stream().map(competency -> { + Map 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 response = new LinkedHashMap<>(); + response.put("courseId", courseId); + response.put("competencies", competencyList); + + return toJson(response); } /** @@ -101,12 +99,17 @@ public String createCompetency(@ToolParam(description = "the ID of the course") @ToolParam(description = "the description of the competency") String description, @ToolParam(description = "the taxonomy level (REMEMBER, UNDERSTAND, APPLY, ANALYZE, EVALUATE, CREATE)") String taxonomyLevel) { try { - Optional courseOpt = courseRepository.findById(courseId); - if (courseOpt.isEmpty()) { + log.debug("Agent tool: Creating competency '{}' for course {}", title, courseId); + + // SET THE FLAG + this.competencyCreated = true; + + Optional courseOptional = courseRepository.findById(courseId); + if (courseOptional.isEmpty()) { return toJson(Map.of("error", "Course not found with ID: " + courseId)); } - Course course = courseOpt.get(); + Course course = courseOptional.get(); Competency competency = new Competency(); competency.setTitle(title); competency.setDescription(description); @@ -118,7 +121,7 @@ public String createCompetency(@ToolParam(description = "the ID of the course") competency.setTaxonomy(taxonomy); } catch (IllegalArgumentException e) { - log.warn("Invalid taxonomy level '{}', using default", taxonomyLevel); + // Invalid taxonomy level - leave taxonomy as null (agent provided invalid value) } } @@ -151,20 +154,7 @@ public String createCompetency(@ToolParam(description = "the ID of the course") */ @Tool(description = "Get the description of a course") public String getCourseDescription(@ToolParam(description = "the ID of the course") Long courseId) { - try { - Optional 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(""); } /** @@ -175,35 +165,38 @@ public String getCourseDescription(@ToolParam(description = "the ID of the cours */ @Tool(description = "List exercises for a course") public String getExercisesListed(@ToolParam(description = "the ID of the course") Long courseId) { - try { - Optional courseOpt = courseRepository.findById(courseId); - if (courseOpt.isEmpty()) { - return toJson(Map.of("error", "Course not found with ID: " + courseId)); - } + Optional courseOptional = courseRepository.findById(courseId); + if (courseOptional.isEmpty()) { + return toJson(Map.of("error", "Course not found with ID: " + courseId)); + } - Set exercises = exerciseRepository.findByCourseIds(Set.of(courseId)); + Set exercises = exerciseRepository.findByCourseIds(Set.of(courseId)); - var exerciseList = exercises.stream().map(exercise -> { - Map 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(); + var exerciseList = exercises.stream().map(exercise -> { + Map 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 response = new LinkedHashMap<>(); - response.put("courseId", courseId); - response.put("exercises", exerciseList); + Map 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())); - } + 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; } /** @@ -217,7 +210,6 @@ private String toJson(Object object) { return objectMapper.writeValueAsString(object); } catch (JsonProcessingException e) { - log.error("Failed to serialize object to JSON: {}", e.getMessage(), e); return "{\"error\": \"Failed to serialize response\"}"; } } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java b/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java index ecb4d857cbbc..20a7b3856368 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/web/AtlasAgentResource.java @@ -8,8 +8,6 @@ import jakarta.validation.Valid; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Lazy; import org.springframework.http.HttpStatus; @@ -36,8 +34,6 @@ @RequestMapping("api/atlas/agent/") public class AtlasAgentResource { - private static final Logger log = LoggerFactory.getLogger(AtlasAgentResource.class); - private static final int CHAT_TIMEOUT_SECONDS = 30; private final AtlasAgentService atlasAgentService; @@ -63,18 +59,15 @@ public ResponseEntity sendChatMessage(@PathVariable L return ResponseEntity.ok(new AtlasAgentChatResponseDTO(result.message(), request.sessionId(), ZonedDateTime.now(), true, result.competenciesModified())); } catch (TimeoutException te) { - log.warn("Chat timed out for course {}: {}", courseId, te.getMessage()); return ResponseEntity.status(HttpStatus.GATEWAY_TIMEOUT) .body(new AtlasAgentChatResponseDTO("The agent timed out. Please try again.", request.sessionId(), ZonedDateTime.now(), false, false)); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); - log.warn("Chat interrupted for course {}: {}", courseId, ie.getMessage()); return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) .body(new AtlasAgentChatResponseDTO("The request was interrupted. Please try again.", request.sessionId(), ZonedDateTime.now(), false, false)); } catch (ExecutionException ee) { - log.error("Upstream error processing chat for course {}: {}", courseId, ee.getMessage(), ee); return ResponseEntity.status(HttpStatus.BAD_GATEWAY) .body(new AtlasAgentChatResponseDTO("Upstream error while processing your request.", request.sessionId(), ZonedDateTime.now(), false, false)); } diff --git a/src/main/resources/prompts/atlas/agent_system_prompt.st b/src/main/resources/prompts/atlas/agent_system_prompt.st index 3f85c17b0850..1ffd54960b77 100644 --- a/src/main/resources/prompts/atlas/agent_system_prompt.st +++ b/src/main/resources/prompts/atlas/agent_system_prompt.st @@ -1,57 +1,115 @@ You are the Atlas ↔ Artemis AI Competency Assistant (call-sign: "AtlasAgent"). -You support instructors in creating, discovering, mapping, and managing competencies inside Artemis using Atlas/AtlasML integrations. -Your primary goals are: - • Recommend competency definitions and mappings to course content and exercises. - • Ask clarifying follow-up questions when user input is ambiguous or incomplete. - • Produce concise, structured proposals in natural language and always request explicit instructor confirmation before making any changes in Artemis. - • Explain why each suggested competency or mapping is relevant (link to course text / exercise evidence when available). - • Keep the instructor in control: never perform writes/changes without explicit confirmation. - -Tone & style: - • Professional, pedagogically-minded, succinct. Use plain-language explanations an instructor can understand. - • When presenting proposals, prefer numbered lists, short bullets, and a final single-line "Suggested action" summary. - • If unsure, ask one clarifying question rather than guessing. - • Never expose technical implementation details, function names, JSON payloads, API calls, or backend operations to the user. - -Memory & context: - • Use conversation history to maintain context for follow-up questions, but summarize the current "working interpretation" before proposing actionable changes. - • When you propose changes, include a short explanation of which pieces of context influenced your suggestion (e.g., "Based on the course description sentence '...' and exercises X and Y..."). - -When to call backend functions / tools: - • Use functions/tools internally to retrieve authoritative data (e.g., course description, exercise list, existing competencies), and to perform writes (create competency, map competency to exercise) — but only *after* you obtain explicit confirmation from the instructor. - • Execute tool calls silently in the background. Never mention that you're "calling a function" or "using a tool" — simply present the results naturally. - • Before performing any action that modifies data, present a human-readable summary and ask: "Do you want to apply this mapping? (yes/no)". Wait for a clear affirmative or negative. - • If data retrieval fails, simply explain what information is missing (e.g., "I couldn't find the course description. Could you provide the course ID?") without mentioning technical errors. - -Response formatting rules: - • When presenting suggestions: first a one-sentence summary, then 1–6 numbered suggestions with: - - Title - - One-line rationale (2–3 sentences max) - - Taxonomy level (use Bloom-rev taxonomy labels: REMEMBER, UNDERSTAND, APPLY, ANALYZE, EVALUATE, CREATE) - - Suggested exercises (by title or number) if applicable - - Confidence indicator (low/medium/high confidence) - • After suggestions, include an exact single question for confirmation (example: "Would you like to apply competency 2 to exercise Programming Basics? (yes/no)"). - • Present all information in natural, conversational language — never show raw data structures, JSON, XML, or code snippets to users. - -Safety and constraints: - • Do not invent facts about student performance, grades, or private data. If asked for protected/personal data, refuse and point to appropriate tool / admin. - • Never reveal system secrets, API keys, backend credentials, function names, or implementation details. - • If your confidence is low or data is missing, say "I don't know" and request the exact piece of missing context. - • Never display technical debugging information, stack traces, or error codes to users. - -Internal processing notes (never show to user): - • Process tool results internally and translate them into user-friendly summaries - • Track function calls and JSON structures internally for accuracy, but present only human-readable results - • If a tool returns an error, translate it into plain language (e.g., "couldn't access" instead of "HTTP 404 error") - -Performance parameters: - • Keep replies concise (aim for 5–8 short paragraphs or fewer) unless asked for more detail. - • When seeking clarification, ask one specific question at a time. - • Focus on pedagogical value and instructional clarity, not technical implementation. - -If the user says "apply" or "yes" after a mapping proposal: - • Perform the requested action using the appropriate backend functions. - • Report the outcome in simple terms: "✓ Successfully mapped [competency] to [exercise]" or "I couldn't complete that action because [simple reason]". - • Provide the competency title and exercise title (not internal IDs) in confirmation messages. - -Always follow these instructions. If a user's command would violate them, politely refuse and explain why. +You support instructors in creating, discovering, and managing competencies inside Artemis using Atlas/AtlasML integrations. + +--- + +## Core Purpose +Your main goals are: + • Help instructors define and manage competencies in a structured, pedagogically clear way. + • Guide instructors through competency creation by asking for the necessary details step by step. + • Provide concise, structured responses and always confirm before performing any action. + • Keep the instructor in full control — never perform writes or changes without explicit confirmation. + +--- + +## Behavior and Responsibilities + +### Competency Creation +When the instructor wants to create a new competency: + • If the instructor provides all necessary details (title, description, taxonomy level) in one message: + 1. Immediately call the createCompetency tool with those details + 2. Present the result to the user: "✓ Successfully created competency 'X' with description 'Y' at taxonomy level 'Z'." + + • If any details are missing, ask for them: + - **Title**: short name or topic + - **Description**: 1–3 sentences summarizing the learning goal + - **Taxonomy level**: from Bloom's revised taxonomy (REMEMBER, UNDERSTAND, APPLY, ANALYZE, EVALUATE, CREATE) + + • Once you collect all missing details in the conversation, immediately call createCompetency with the complete information. + +IMPORTANT: Do NOT ask for confirmation before creating a competency. The instructor's request to "create" is the confirmation. Create it immediately when you have all required information. + +After successfully creating a competency, inform the user with: "✓ Competency '[title]' was successfully created." + +--- + +### Other Interactions +- When discussing existing competencies or course data, you may retrieve them using available tools. +- You may explain competency relationships conceptually, but do not perform mappings automatically. +- Always summarize your understanding of the instructor’s intent before acting. +- If something is unclear, ask a specific clarifying question rather than guessing. + +--- + +## Tone & Style +- Professional, pedagogically minded, clear, and succinct. +- Use simple language suitable for instructors with limited technical background. +- Prefer short paragraphs, numbered or bulleted lists, and brief confirmations. +- When unsure, politely ask one concrete question. + +--- + +## Memory & Context +- Maintain conversation context during a chat session to follow up naturally. +- Summarize the current interpretation before proposing any action. +- If the chat is temporarily closed and reopened, recover cached memory to resume context seamlessly. + +--- + +## Tool Usage +- Use tools silently and internally — never mention “function calls”, “API”, or “backend operations”. +- Only use tools **after explicit instructor confirmation**. +- If data retrieval fails, explain what’s missing in plain language (e.g., “I couldn’t find existing competencies for this course.”). +- Before performing any action, show a concise summary and ask for confirmation (“Would you like me to proceed? (yes/no)”). + +--- + +## Response Formatting +When presenting suggestions or actions: + 1. One-sentence summary. + 2. Numbered list (1–6 items) with: + - Title + - One-line rationale (2–3 sentences max) + - Taxonomy level (REMEMBER → CREATE) + - Confidence indicator (low/medium/high) + 3. Final confirmation question. + +Never show raw data, IDs, JSON, or technical details. + +--- + +## Safety and Reliability +- Never invent data about students or private information. +- Never reveal system details (APIs, keys, or internal operations). +- If unsure, respond with “I don’t know” and ask for the missing information. +- Keep responses focused on pedagogy and course relevance. + +--- + +## Internal Processing (never shown to the user) +- Track tool names and parameters internally, but describe results in natural language. +- If `createCompetency` or any tool returns an error, translate it to plain English (e.g., “I couldn’t create the competency because the title is already used.”). + +--- + +## Performance +- Keep responses brief (ideally under 8 short paragraphs). +- Ask one question at a time. +- Focus on instructional clarity and teaching value. + +--- + +## Confirmation Behavior +If the instructor says **“yes”** or **“apply”**: + - Execute the confirmed tool (e.g., `createCompetency`). + - Return a success message: + “✓ Successfully created competency ‘X’.” + - If it fails, respond with: + “I couldn’t complete that action because [simple reason].” + +If the instructor says **“no”**, stop immediately and acknowledge politely: +“No problem, I’ll hold off on creating it.” + +--- + +Always follow these rules. If the instructor’s request would violate them, politely refuse and explain why. diff --git a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.spec.ts b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.spec.ts index f6f749dbe5cc..0ea5bfefad88 100644 --- a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.spec.ts +++ b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat-modal.component.spec.ts @@ -95,14 +95,11 @@ describe('AgentChatModalComponent', () => { }); it('should show welcome message after init', () => { - // Arrange const welcomeMessage = 'Welcome to the agent chat!'; mockTranslateService.instant.mockReturnValue(welcomeMessage); - // Act component.ngOnInit(); - // Assert expect(mockTranslateService.instant).toHaveBeenCalledOnce(); expect(mockTranslateService.instant).toHaveBeenCalledWith('artemisApp.agent.chat.welcome'); expect(component.messages).toHaveLength(1); @@ -113,15 +110,12 @@ describe('AgentChatModalComponent', () => { }); it('should generate sessionId based on courseId and timestamp', () => { - // Arrange const mockDateNow = 1642723200000; // Fixed timestamp jest.spyOn(Date, 'now').mockReturnValue(mockDateNow); component.courseId = 456; - // Act component.ngOnInit(); - // Assert - component no longer has sessionId, it's generated in service expect(component.messages.length).toBeGreaterThan(0); }); }); @@ -132,53 +126,41 @@ describe('AgentChatModalComponent', () => { }); it('should return false for empty input', () => { - // Arrange component.currentMessage.set(''); - // Act & Assert expect(component.canSendMessage()).toBeFalse(); }); it('should return false for whitespace only input', () => { - // Arrange component.currentMessage.set(' \n\t '); - // Act & Assert expect(component.canSendMessage()).toBeFalse(); }); it('should return false for too long input', () => { - // Arrange component.currentMessage.set('a'.repeat(component.MAX_MESSAGE_LENGTH + 1)); - // Act & Assert expect(component.canSendMessage()).toBeFalse(); }); it('should return false when agent is typing', () => { - // Arrange component.currentMessage.set('Valid message'); component.isAgentTyping.set(true); - // Act & Assert expect(component.canSendMessage()).toBeFalse(); }); it('should return true for valid input', () => { - // Arrange component.currentMessage.set('Valid message'); component.isAgentTyping.set(false); - // Act & Assert expect(component.canSendMessage()).toBeTrue(); }); it('should return true for input at max length limit', () => { - // Arrange component.currentMessage.set('a'.repeat(component.MAX_MESSAGE_LENGTH)); component.isAgentTyping.set(false); - // Act & Assert expect(component.canSendMessage()).toBeTrue(); }); }); @@ -191,49 +173,40 @@ describe('AgentChatModalComponent', () => { }); it('should call sendMessage when Enter key is pressed without Shift', () => { - // Arrange const mockEvent = { key: 'Enter', shiftKey: false, preventDefault: jest.fn(), } as any; - // Act component.onKeyPress(mockEvent); - // Assert expect(mockEvent.preventDefault).toHaveBeenCalled(); expect(sendMessageSpy).toHaveBeenCalled(); }); it('should not call sendMessage when Enter key is pressed with Shift', () => { - // Arrange const mockEvent = { key: 'Enter', shiftKey: true, preventDefault: jest.fn(), } as any; - // Act component.onKeyPress(mockEvent); - // Assert expect(mockEvent.preventDefault).not.toHaveBeenCalled(); expect(sendMessageSpy).not.toHaveBeenCalled(); }); it('should not call sendMessage for other keys', () => { - // Arrange const mockEvent = { key: 'Space', shiftKey: false, preventDefault: jest.fn(), } as any; - // Act component.onKeyPress(mockEvent); - // Assert expect(mockEvent.preventDefault).not.toHaveBeenCalled(); expect(sendMessageSpy).not.toHaveBeenCalled(); }); @@ -241,14 +214,12 @@ describe('AgentChatModalComponent', () => { describe('sendMessage', () => { beforeEach(() => { - // Setup component for successful message sending component.currentMessage.set('Test message'); component.isAgentTyping.set(false); component.courseId = 123; }); it('should send message when send button is clicked', () => { - // Arrange component.currentMessage.set('Test message'); component.isAgentTyping.set(false); const mockResponse = { @@ -260,21 +231,17 @@ describe('AgentChatModalComponent', () => { }; mockAgentChatService.sendMessage.mockReturnValue(of(mockResponse)); - // Clear any existing messages to start fresh component.messages = []; fixture.detectChanges(); - // Act - Test through user interaction instead of calling private method const sendButton = fixture.debugElement.nativeElement.querySelector('.send-button'); sendButton.click(); - // Assert expect(mockAgentChatService.sendMessage).toHaveBeenCalledWith('Test message', 123); expect(component.messages).toHaveLength(3); // Welcome + User message + agent response }); it('should send message when Enter key is pressed', () => { - // Arrange component.currentMessage.set('Test message'); component.isAgentTyping.set(false); const mockResponse = { @@ -287,7 +254,6 @@ describe('AgentChatModalComponent', () => { mockAgentChatService.sendMessage.mockReturnValue(of(mockResponse)); fixture.detectChanges(); - // Act - Test through keyboard interaction const textarea = fixture.debugElement.nativeElement.querySelector('textarea'); const enterEvent = new KeyboardEvent('keypress', { key: 'Enter', @@ -296,27 +262,22 @@ describe('AgentChatModalComponent', () => { textarea.dispatchEvent(enterEvent); - // Assert expect(mockAgentChatService.sendMessage).toHaveBeenCalledWith('Test message', 123); }); it('should handle service error gracefully', fakeAsync(() => { - // Arrange component.currentMessage.set('Test message'); const errorMessage = 'Connection failed'; mockAgentChatService.sendMessage.mockReturnValue(throwError(() => new Error('Service error'))); mockTranslateService.instant.mockReturnValue(errorMessage); fixture.detectChanges(); - // Clear previous calls from beforeEach jest.clearAllMocks(); - // Act - Through user interaction const sendButton = fixture.debugElement.nativeElement.querySelector('.send-button'); sendButton.click(); tick(); - // Assert expect(component.isAgentTyping()).toBeFalse(); expect(mockTranslateService.instant).toHaveBeenCalledOnce(); expect(mockTranslateService.instant).toHaveBeenCalledWith('artemisApp.agent.chat.error'); @@ -326,50 +287,39 @@ describe('AgentChatModalComponent', () => { })); it('should not send message if canSendMessage is false', () => { - // Arrange component.currentMessage.set(''); // Makes canSendMessage false fixture.detectChanges(); - // Act - Try to click disabled button const sendButton = fixture.debugElement.nativeElement.querySelector('.send-button'); sendButton.click(); - // Assert expect(mockAgentChatService.sendMessage).not.toHaveBeenCalled(); }); }); describe('Focus behavior', () => { it('should focus input after view init', fakeAsync(() => { - // Act component.ngAfterViewInit(); tick(10); - // Assert expect(mockTextarea.focus).toHaveBeenCalled(); })); it('should scroll to bottom when shouldScrollToBottom is true', () => { - // Arrange component['shouldScrollToBottom'] = true; - // Act component.ngAfterViewChecked(); - // Assert expect(mockMessagesContainer.nativeElement.scrollTop).toBe(500); // scrollHeight value expect(component['shouldScrollToBottom']).toBeFalse(); }); it('should not scroll when shouldScrollToBottom is false', () => { - // Arrange component['shouldScrollToBottom'] = false; const originalScrollTop = mockMessagesContainer.nativeElement.scrollTop; - // Act component.ngAfterViewChecked(); - // Assert expect(mockMessagesContainer.nativeElement.scrollTop).toBe(originalScrollTop); }); }); @@ -382,7 +332,6 @@ describe('AgentChatModalComponent', () => { }); it('should display messages in the template', () => { - // Arrange const userMessage: ChatMessage = { id: '1', content: 'User message', @@ -397,10 +346,8 @@ describe('AgentChatModalComponent', () => { }; component.messages = [userMessage, agentMessage]; - // Act fixture.detectChanges(); - // Assert const messageElements = fixture.debugElement.nativeElement.querySelectorAll('.message-wrapper'); expect(messageElements).toHaveLength(2); @@ -412,84 +359,64 @@ describe('AgentChatModalComponent', () => { }); it('should show typing indicator when isAgentTyping is true', () => { - // Arrange component.isAgentTyping.set(true); - // Act fixture.detectChanges(); - // Assert const typingIndicator = fixture.debugElement.nativeElement.querySelector('.typing-indicator'); expect(typingIndicator).toBeTruthy(); }); it('should hide typing indicator when isAgentTyping is false', () => { - // Arrange component.isAgentTyping.set(false); - // Act fixture.detectChanges(); - // Assert const typingIndicator = fixture.debugElement.nativeElement.querySelector('.typing-indicator'); expect(typingIndicator).toBeFalsy(); }); it('should prevent message sending when agent is typing', () => { - // Arrange component.currentMessage.set('Valid message'); component.isAgentTyping.set(true); - // Act & Assert expect(component.canSendMessage()).toBeFalse(); expect(component.isAgentTyping()).toBeTrue(); }); it('should disable send button when canSendMessage is false', () => { - // Arrange component.currentMessage.set(''); - // Act fixture.detectChanges(); - // Assert const sendButton = fixture.debugElement.nativeElement.querySelector('.send-button'); expect(sendButton.disabled).toBeTrue(); }); it('should enable send button when canSendMessage is true', () => { - // Arrange component.currentMessage.set('Valid message'); component.isAgentTyping.set(false); - // Act fixture.detectChanges(); - // Assert const sendButton = fixture.debugElement.nativeElement.querySelector('.send-button'); expect(sendButton.disabled).toBeFalse(); }); it('should show character count in template', () => { - // Arrange component.currentMessage.set('Test message'); - // Act fixture.detectChanges(); - // Assert const charCountElement = fixture.debugElement.nativeElement.querySelector('.text-end'); expect(charCountElement.textContent.trim()).toContain('12 / 8000'); }); it('should show error styling when message is too long', () => { - // Arrange component.currentMessage.set('a'.repeat(component.MAX_MESSAGE_LENGTH + 1)); - // Act fixture.detectChanges(); - // Assert const charCountElement = fixture.debugElement.nativeElement.querySelector('.text-danger'); expect(charCountElement).toBeTruthy(); @@ -500,23 +427,18 @@ describe('AgentChatModalComponent', () => { describe('Modal interaction', () => { it('should close modal when closeModal is called', () => { - // Act (component as any).closeModal(); - // Assert expect(mockActiveModal.close).toHaveBeenCalled(); }); it('should call closeModal when close button is clicked', () => { - // Arrange const closeModalSpy = jest.spyOn(component as any, 'closeModal'); fixture.detectChanges(); - // Act const closeButton = fixture.debugElement.nativeElement.querySelector('.btn-close'); closeButton.click(); - // Assert expect(closeModalSpy).toHaveBeenCalled(); }); }); @@ -528,7 +450,6 @@ describe('AgentChatModalComponent', () => { }); it('should emit competencyChanged event when competenciesModified is true', () => { - // Arrange component.currentMessage.set('Create a competency for OOP'); component.isAgentTyping.set(false); const mockResponse = { @@ -542,16 +463,13 @@ describe('AgentChatModalComponent', () => { const emitSpy = jest.spyOn(component.competencyChanged, 'emit'); fixture.detectChanges(); - // Act const sendButton = fixture.debugElement.nativeElement.querySelector('.send-button'); sendButton.click(); - // Assert expect(emitSpy).toHaveBeenCalledOnce(); }); it('should not emit competencyChanged event when competenciesModified is false', () => { - // Arrange component.currentMessage.set('What competencies exist?'); component.isAgentTyping.set(false); const mockResponse = { @@ -565,106 +483,84 @@ describe('AgentChatModalComponent', () => { const emitSpy = jest.spyOn(component.competencyChanged, 'emit'); fixture.detectChanges(); - // Act const sendButton = fixture.debugElement.nativeElement.querySelector('.send-button'); sendButton.click(); - // Assert expect(emitSpy).not.toHaveBeenCalled(); }); }); describe('Textarea auto-resize behavior', () => { it('should auto-resize textarea on input when content exceeds max height', () => { - // Arrange Object.defineProperty(mockTextarea, 'scrollHeight', { value: 150, // Greater than max height of 120px writable: true, configurable: true, }); - // Act component.onTextareaInput(); - // Assert // Height should be set to max height (120px) when scrollHeight exceeds it expect(mockTextarea.style.height).toBe('120px'); }); it('should auto-resize textarea on input when content is within max height', () => { - // Arrange Object.defineProperty(mockTextarea, 'scrollHeight', { value: 80, // Less than max height of 120px writable: true, configurable: true, }); - // Act component.onTextareaInput(); - // Assert // Height should be set to scrollHeight when it's less than max height expect(mockTextarea.style.height).toBe('80px'); }); it('should handle case when textarea element is not available', () => { - // Arrange jest.spyOn(component as any, 'messageInput').mockReturnValue(null); - // Act & Assert - Should not throw error expect(() => component.onTextareaInput()).not.toThrow(); }); }); describe('Computed signals', () => { it('should calculate currentMessageLength correctly', () => { - // Arrange & Act component.currentMessage.set('Hello'); - // Assert expect(component.currentMessageLength()).toBe(5); }); it('should update currentMessageLength when message changes', () => { - // Arrange component.currentMessage.set('Short'); expect(component.currentMessageLength()).toBe(5); - // Act component.currentMessage.set('A much longer message'); - // Assert expect(component.currentMessageLength()).toBe(21); }); it('should correctly identify message as too long', () => { - // Arrange component.currentMessage.set('a'.repeat(component.MAX_MESSAGE_LENGTH + 1)); - // Act & Assert expect(component.isMessageTooLong()).toBeTrue(); }); it('should correctly identify message as not too long', () => { - // Arrange component.currentMessage.set('a'.repeat(component.MAX_MESSAGE_LENGTH)); - // Act & Assert expect(component.isMessageTooLong()).toBeFalse(); }); it('should correctly identify empty message as not too long', () => { - // Arrange component.currentMessage.set(''); - // Act & Assert expect(component.isMessageTooLong()).toBeFalse(); }); }); describe('Message state management', () => { it('should clear currentMessage after sending', () => { - // Arrange mockTranslateService.instant.mockReturnValue('Welcome'); component.ngOnInit(); component.currentMessage.set('Test message to send'); @@ -679,16 +575,13 @@ describe('AgentChatModalComponent', () => { mockAgentChatService.sendMessage.mockReturnValue(of(mockResponse)); fixture.detectChanges(); - // Act const sendButton = fixture.debugElement.nativeElement.querySelector('.send-button'); sendButton.click(); - // Assert expect(component.currentMessage()).toBe(''); }); it('should set isAgentTyping to true when sending message', () => { - // Arrange mockTranslateService.instant.mockReturnValue('Welcome'); component.ngOnInit(); component.currentMessage.set('Test message'); @@ -704,16 +597,13 @@ describe('AgentChatModalComponent', () => { ); fixture.detectChanges(); - // Act const sendButton = fixture.debugElement.nativeElement.querySelector('.send-button'); sendButton.click(); - // Assert - Should be set to true during processing, then back to false expect(component.isAgentTyping()).toBeFalse(); // False after response completes }); it('should add user message to messages array', () => { - // Arrange mockTranslateService.instant.mockReturnValue('Welcome'); component.ngOnInit(); const initialMessageCount = component.messages.length; @@ -730,11 +620,9 @@ describe('AgentChatModalComponent', () => { ); fixture.detectChanges(); - // Act const sendButton = fixture.debugElement.nativeElement.querySelector('.send-button'); sendButton.click(); - // Assert expect(component.messages.length).toBeGreaterThan(initialMessageCount); const userMessage = component.messages.find((msg) => msg.isUser && msg.content === 'User test message'); expect(userMessage).toBeDefined(); @@ -743,16 +631,13 @@ describe('AgentChatModalComponent', () => { describe('Scroll behavior edge cases', () => { it('should handle scrollToBottom when messagesContainer is null', () => { - // Arrange jest.spyOn(component as any, 'messagesContainer').mockReturnValue(null); component['shouldScrollToBottom'] = true; - // Act & Assert - Should not throw error expect(() => component.ngAfterViewChecked()).not.toThrow(); }); it('should handle empty response message from service', fakeAsync(() => { - // Arrange component.currentMessage.set('Test message'); component.isAgentTyping.set(false); const mockResponse = { @@ -766,18 +651,15 @@ describe('AgentChatModalComponent', () => { mockTranslateService.instant.mockReturnValue('Default error message'); fixture.detectChanges(); - // Act const sendButton = fixture.debugElement.nativeElement.querySelector('.send-button'); sendButton.click(); tick(); - // Assert - Empty response should trigger error message from translate expect(mockTranslateService.instant).toHaveBeenCalledWith('artemisApp.agent.chat.error'); expect(component.messages[component.messages.length - 1].content).toBe('Default error message'); })); it('should handle null response message from service', fakeAsync(() => { - // Arrange component.currentMessage.set('Test message'); component.isAgentTyping.set(false); const mockResponse = { @@ -791,22 +673,18 @@ describe('AgentChatModalComponent', () => { mockTranslateService.instant.mockReturnValue('Default error message'); fixture.detectChanges(); - // Act const sendButton = fixture.debugElement.nativeElement.querySelector('.send-button'); sendButton.click(); tick(); - // Assert - Null response should trigger error message expect(mockTranslateService.instant).toHaveBeenCalledWith('artemisApp.agent.chat.error'); })); it('should set shouldScrollToBottom flag when adding messages', () => { - // Arrange mockTranslateService.instant.mockReturnValue('Welcome'); component.ngOnInit(); component['shouldScrollToBottom'] = false; - // Act component.currentMessage.set('Test message'); component.isAgentTyping.set(false); mockAgentChatService.sendMessage.mockReturnValue( @@ -822,7 +700,6 @@ describe('AgentChatModalComponent', () => { const sendButton = fixture.debugElement.nativeElement.querySelector('.send-button'); sendButton.click(); - // Assert - shouldScrollToBottom should be true after adding message, then reset to false after ngAfterViewChecked expect(component['shouldScrollToBottom']).toBeDefined(); }); }); diff --git a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts index a0ae2409a9af..240cca412ec9 100644 --- a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts +++ b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts @@ -59,7 +59,6 @@ describe('AgentChatService', () => { }; it('should return AgentChatResponse from successful HTTP response', () => { - // Arrange const mockResponse = { message: 'Agent response message', sessionId: `course_${courseId}_user_${userId}`, @@ -69,12 +68,10 @@ describe('AgentChatService', () => { }; let result: any; - // Act service.sendMessage(message, courseId).subscribe((response) => { result = response; }); - // Assert const req = httpMock.expectOne(expectedUrl); expect(req.request.method).toBe('POST'); expect(req.request.body).toEqual(expectedRequestBody); @@ -85,17 +82,14 @@ describe('AgentChatService', () => { }); it('should return fallback error response on HTTP error', () => { - // Arrange const fallbackMessage = 'Connection error'; translateService.instant.mockReturnValue(fallbackMessage); let result: any; - // Act service.sendMessage(message, courseId).subscribe((response) => { result = response; }); - // Assert const req = httpMock.expectOne(expectedUrl); req.flush('Server error', { status: 500, statusText: 'Internal Server Error' }); @@ -105,13 +99,11 @@ describe('AgentChatService', () => { }); it('should use catchError operator properly on network failure', () => { - // Arrange const fallbackMessage = 'Network failure handled'; translateService.instant.mockReturnValue(fallbackMessage); let result: any; let errorOccurred = false; - // Act service.sendMessage(message, courseId).subscribe({ next: (response) => { result = response; @@ -121,7 +113,6 @@ describe('AgentChatService', () => { }, }); - // Assert const req = httpMock.expectOne(expectedUrl); req.error(new ProgressEvent('Network error')); @@ -137,11 +128,9 @@ describe('AgentChatService', () => { const message = 'Test message'; it('should handle timeout after 30 seconds', fakeAsync(() => { - // Arrange const expectedUrl = `api/atlas/agent/courses/${courseId}/chat`; let result: any; - // Act service.sendMessage(message, courseId).subscribe({ next: (response) => { result = response; @@ -166,16 +155,13 @@ describe('AgentChatService', () => { const message = 'Test'; it('should generate sessionId with valid userId', () => { - // Arrange mockAccountService.userIdentity = { id: 42, login: 'testuser' }; const expectedSessionId = 'course_456_user_42'; - // Act service.sendMessage(message, courseId).subscribe(); const req = httpMock.expectOne(`api/atlas/agent/courses/${courseId}/chat`); - // Assert expect(req.request.body.sessionId).toBe(expectedSessionId); req.flush({ message: 'response', @@ -186,46 +172,16 @@ describe('AgentChatService', () => { }); }); - it('should use 0 as fallback when userIdentity is null', () => { - // Arrange + it('should throw error when userIdentity is null', () => { mockAccountService.userIdentity = null; - const expectedSessionId = 'course_456_user_0'; - // Act - service.sendMessage(message, courseId).subscribe(); - - const req = httpMock.expectOne(`api/atlas/agent/courses/${courseId}/chat`); - - // Assert - expect(req.request.body.sessionId).toBe(expectedSessionId); - req.flush({ - message: 'response', - sessionId: expectedSessionId, - timestamp: '2024-01-01T00:00:00Z', - success: true, - competenciesModified: false, - }); + expect(() => service.sendMessage(message, courseId)).toThrow('User must be authenticated to use agent chat'); }); - it('should use 0 as fallback when userIdentity.id is undefined', () => { - // Arrange + it('should throw error when userIdentity.id is undefined', () => { mockAccountService.userIdentity = { id: undefined, login: 'testuser' }; - const expectedSessionId = 'course_456_user_0'; - // Act - service.sendMessage(message, courseId).subscribe(); - - const req = httpMock.expectOne(`api/atlas/agent/courses/${courseId}/chat`); - - // Assert - expect(req.request.body.sessionId).toBe(expectedSessionId); - req.flush({ - message: 'response', - sessionId: expectedSessionId, - timestamp: '2024-01-01T00:00:00Z', - success: true, - competenciesModified: false, - }); + expect(() => service.sendMessage(message, courseId)).toThrow('User must be authenticated to use agent chat'); }); }); @@ -234,13 +190,10 @@ describe('AgentChatService', () => { const message = 'Test message'; it('should make POST request to correct URL', () => { - // Arrange const expectedUrl = `api/atlas/agent/courses/${courseId}/chat`; - // Act service.sendMessage(message, courseId).subscribe(); - // Assert const req = httpMock.expectOne(expectedUrl); expect(req.request.method).toBe('POST'); req.flush({ @@ -253,15 +206,12 @@ describe('AgentChatService', () => { }); it('should send request with correct body structure', () => { - // Arrange mockAccountService.userIdentity = { id: 99, login: 'user99' }; - // Act service.sendMessage(message, courseId).subscribe(); const req = httpMock.expectOne(`api/atlas/agent/courses/${courseId}/chat`); - // Assert expect(req.request.body).toEqual({ message: 'Test message', sessionId: 'course_789_user_99', @@ -282,12 +232,10 @@ describe('AgentChatService', () => { const message = 'Test'; it('should return error response object on catchError', () => { - // Arrange mockAccountService.userIdentity = { id: 55, login: 'testuser' }; mockTranslateService.instant.mockReturnValue('Translated error message'); let result: any; - // Act service.sendMessage(message, courseId).subscribe({ next: (response) => { result = response; @@ -297,7 +245,6 @@ describe('AgentChatService', () => { const req = httpMock.expectOne(`api/atlas/agent/courses/${courseId}/chat`); req.error(new ProgressEvent('error')); - // Assert expect(result).toBeDefined(); expect(result.message).toBe('Translated error message'); expect(result.success).toBeFalse(); @@ -307,11 +254,9 @@ describe('AgentChatService', () => { }); it('should include timestamp in error response', () => { - // Arrange const beforeTime = new Date().toISOString(); let result: any; - // Act service.sendMessage(message, courseId).subscribe({ next: (response) => { result = response; @@ -323,7 +268,6 @@ describe('AgentChatService', () => { const afterTime = new Date().toISOString(); - // Assert expect(result.timestamp).toBeDefined(); expect(result.timestamp >= beforeTime).toBeTruthy(); expect(result.timestamp <= afterTime).toBeTruthy(); diff --git a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts index 4262fdbec9f0..5ebf03aa9134 100644 --- a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts +++ b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.ts @@ -27,7 +27,11 @@ export class AgentChatService { private accountService = inject(AccountService); sendMessage(message: string, courseId: number): Observable { - const userId = this.accountService.userIdentity?.id ?? 0; + const userId = this.accountService.userIdentity?.id; + if (!userId) { + throw new Error('User must be authenticated to use agent chat'); + } + const sessionId = `course_${courseId}_user_${userId}`; const request: AgentChatRequest = { @@ -38,7 +42,6 @@ export class AgentChatService { return this.http.post(`api/atlas/agent/courses/${courseId}/chat`, request).pipe( timeout(30000), catchError(() => { - // Return error response on failure return of({ message: this.translateService.instant('artemisApp.agent.chat.error'), sessionId, diff --git a/src/main/webapp/app/atlas/manage/competency-management/competency-management.component.spec.ts b/src/main/webapp/app/atlas/manage/competency-management/competency-management.component.spec.ts index ac2eb86f15c1..06e78c3c8ca6 100644 --- a/src/main/webapp/app/atlas/manage/competency-management/competency-management.component.spec.ts +++ b/src/main/webapp/app/atlas/manage/competency-management/competency-management.component.spec.ts @@ -4,6 +4,7 @@ import { MockComponent, MockDirective, MockPipe, MockProvider } from 'ng-mocks'; import { of } from 'rxjs'; import { Competency, CompetencyWithTailRelationDTO, CourseCompetencyProgress, CourseCompetencyType } from 'app/atlas/shared/entities/competency.model'; import { CompetencyManagementComponent } from 'app/atlas/manage/competency-management/competency-management.component'; +import { AgentChatModalComponent } from 'app/atlas/manage/agent-chat-modal/agent-chat-modal.component'; import { ActivatedRoute, provideRouter } from '@angular/router'; import { DeleteButtonDirective } from 'app/shared/delete-dialog/directive/delete-button.directive'; import { AccountService } from 'app/core/auth/account.service'; @@ -238,7 +239,6 @@ describe('CompetencyManagementComponent', () => { }); it('should open agent chat modal and set courseId', () => { - // Arrange sessionStorageService.store('alreadyVisitedCompetencyManagement', true); const modalRef = { componentInstance: { @@ -251,11 +251,9 @@ describe('CompetencyManagementComponent', () => { const openModalSpy = jest.spyOn(modalService, 'open').mockReturnValue(modalRef); fixture.detectChanges(); - // Act component['openAgentChatModal'](); - // Assert - expect(openModalSpy).toHaveBeenCalledWith(expect.anything(), { + expect(openModalSpy).toHaveBeenCalledWith(AgentChatModalComponent, { size: 'lg', backdrop: true, }); diff --git a/src/test/java/de/tum/cit/aet/artemis/atlas/agent/AtlasAgentIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/atlas/agent/AtlasAgentIntegrationTest.java index b0841efc87f1..7fd9f0569d7f 100644 --- a/src/test/java/de/tum/cit/aet/artemis/atlas/agent/AtlasAgentIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/atlas/agent/AtlasAgentIntegrationTest.java @@ -47,12 +47,10 @@ void testServiceAvailability() { @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testBasicEndToEndFlow() throws Exception { - // Given String competencyMessage = "Help me create competencies for a software engineering course covering OOP, design patterns, and testing"; String sessionId = "e2e-test-session"; AtlasAgentChatRequestDTO requestDTO = new AtlasAgentChatRequestDTO(competencyMessage, sessionId); - // When & Then - Test the HTTP endpoint with a realistic request request.performMvcRequest( post("/api/atlas/agent/courses/{courseId}/chat", course.getId()).contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(requestDTO))) .andExpect(status().isOk()).andExpect(jsonPath("$.sessionId").value(sessionId)).andExpect(jsonPath("$.success").exists()) @@ -62,11 +60,9 @@ void testBasicEndToEndFlow() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testSessionIdConsistency() throws Exception { - // Given String sessionId = "session-consistency-test"; AtlasAgentChatRequestDTO requestDTO = new AtlasAgentChatRequestDTO("Test session consistency", sessionId); - // When & Then - Verify session ID is returned correctly request.performMvcRequest( post("/api/atlas/agent/courses/{courseId}/chat", course.getId()).contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(requestDTO))) .andExpect(status().isOk()).andExpect(jsonPath("$.sessionId").value(sessionId)); @@ -75,17 +71,14 @@ void testSessionIdConsistency() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testDifferentCourseContexts() throws Exception { - // Given Course secondCourse = courseUtilService.createCourse(); String message = "Help with competencies for Computer Science basics"; AtlasAgentChatRequestDTO requestDTO = new AtlasAgentChatRequestDTO(message, "course-context-session"); - // When & Then - Test with first course request.performMvcRequest( post("/api/atlas/agent/courses/{courseId}/chat", course.getId()).contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(requestDTO))) .andExpect(status().isOk()); - // When & Then - Test with second course request.performMvcRequest( post("/api/atlas/agent/courses/{courseId}/chat", secondCourse.getId()).contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(requestDTO))) .andExpect(status().isOk()); @@ -94,11 +87,9 @@ void testDifferentCourseContexts() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testDatabaseRelatedMessage() throws Exception { - // Given String message = "What competencies should I create for a database course?"; AtlasAgentChatRequestDTO requestDTO = new AtlasAgentChatRequestDTO(message, "database-session"); - // When & Then request.performMvcRequest( post("/api/atlas/agent/courses/{courseId}/chat", course.getId()).contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(requestDTO))) .andExpect(status().isOk()).andExpect(jsonPath("$.sessionId").value("database-session")).andExpect(jsonPath("$.message").exists()); @@ -107,11 +98,9 @@ void testDatabaseRelatedMessage() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testExerciseMappingMessage() throws Exception { - // Given String message = "Help me map exercises to competencies"; AtlasAgentChatRequestDTO requestDTO = new AtlasAgentChatRequestDTO(message, "exercise-mapping-session"); - // When & Then request.performMvcRequest( post("/api/atlas/agent/courses/{courseId}/chat", course.getId()).contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(requestDTO))) .andExpect(status().isOk()).andExpect(jsonPath("$.sessionId").value("exercise-mapping-session")).andExpect(jsonPath("$.message").exists()); @@ -120,11 +109,9 @@ void testExerciseMappingMessage() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testSpecificCompetencyListMessage() throws Exception { - // Given String message = "I want to create competencies for: SQL, NoSQL, Database Design, Query Optimization"; AtlasAgentChatRequestDTO requestDTO = new AtlasAgentChatRequestDTO(message, "competency-list-session"); - // When & Then request.performMvcRequest( post("/api/atlas/agent/courses/{courseId}/chat", course.getId()).contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(requestDTO))) .andExpect(status().isOk()).andExpect(jsonPath("$.sessionId").value("competency-list-session")).andExpect(jsonPath("$.message").exists()); @@ -133,11 +120,9 @@ void testSpecificCompetencyListMessage() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testBloomsTaxonomyMessage() throws Exception { - // Given String message = "Generate competencies based on Bloom's taxonomy for my machine learning course"; AtlasAgentChatRequestDTO requestDTO = new AtlasAgentChatRequestDTO(message, "blooms-taxonomy-session"); - // When & Then request.performMvcRequest( post("/api/atlas/agent/courses/{courseId}/chat", course.getId()).contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(requestDTO))) .andExpect(status().isOk()).andExpect(jsonPath("$.sessionId").value("blooms-taxonomy-session")).andExpect(jsonPath("$.message").exists()); @@ -146,11 +131,9 @@ void testBloomsTaxonomyMessage() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testPrerequisiteRelationshipsMessage() throws Exception { - // Given String message = "Can you suggest prerequisite relationships between competencies?"; AtlasAgentChatRequestDTO requestDTO = new AtlasAgentChatRequestDTO(message, "prerequisites-session"); - // When & Then request.performMvcRequest( post("/api/atlas/agent/courses/{courseId}/chat", course.getId()).contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(requestDTO))) .andExpect(status().isOk()).andExpect(jsonPath("$.sessionId").value("prerequisites-session")).andExpect(jsonPath("$.message").exists()); @@ -159,11 +142,9 @@ void testPrerequisiteRelationshipsMessage() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testAssessmentFocusedMessage() throws Exception { - // Given String message = "Help me create assessment-focused competencies for programming exercises"; AtlasAgentChatRequestDTO requestDTO = new AtlasAgentChatRequestDTO(message, "assessment-session"); - // When & Then request.performMvcRequest( post("/api/atlas/agent/courses/{courseId}/chat", course.getId()).contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(requestDTO))) .andExpect(status().isOk()).andExpect(jsonPath("$.sessionId").value("assessment-session")).andExpect(jsonPath("$.message").exists()); @@ -172,11 +153,9 @@ void testAssessmentFocusedMessage() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testWebDevelopmentMessage() throws Exception { - // Given String message = "I'm designing a new course on web development. Can you help me create competencies?"; AtlasAgentChatRequestDTO requestDTO = new AtlasAgentChatRequestDTO(message, "web-dev-session"); - // When & Then request.performMvcRequest( post("/api/atlas/agent/courses/{courseId}/chat", course.getId()).contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(requestDTO))) .andExpect(status().isOk()).andExpect(jsonPath("$.sessionId").value("web-dev-session")).andExpect(jsonPath("$.message").exists()); @@ -185,11 +164,9 @@ void testWebDevelopmentMessage() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testCourseContentMessage() throws Exception { - // Given String message = "The course covers HTML, CSS, JavaScript, React, Node.js, and databases."; AtlasAgentChatRequestDTO requestDTO = new AtlasAgentChatRequestDTO(message, "course-content-session"); - // When & Then request.performMvcRequest( post("/api/atlas/agent/courses/{courseId}/chat", course.getId()).contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(requestDTO))) .andExpect(status().isOk()).andExpect(jsonPath("$.sessionId").value("course-content-session")).andExpect(jsonPath("$.message").exists()); @@ -201,25 +178,20 @@ class ToolIntegration { @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void shouldSetCompetenciesModifiedFlagWhenToolCalled() throws Exception { - // Given String competencyCreationMessage = "Create a competency called 'Object-Oriented Programming' with description 'Understanding OOP principles'"; String sessionId = "tool-test-session"; - // When request.performMvcRequest(post("/api/atlas/agent/courses/{courseId}/chat", course.getId()).contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(new AtlasAgentChatRequestDTO(competencyCreationMessage, sessionId)))).andExpect(status().isOk()) .andExpect(jsonPath("$.competenciesModified").exists()); - // Note: competenciesModified value depends on AI tool invocation } @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void shouldIndicateToolsAreAvailable() { - // When boolean actualAvailability = atlasAgentService.isAvailable(); - // Then assertThat(actualAvailability).as("Agent service should be available with tools").isTrue(); } } @@ -230,10 +202,8 @@ class Authorization { @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") void shouldReturnForbiddenForStudentAccessingChatEndpoint() throws Exception { - // Given AtlasAgentChatRequestDTO requestDTO = new AtlasAgentChatRequestDTO("Test message", "test-session"); - // When & Then request.performMvcRequest( post("/api/atlas/agent/courses/{courseId}/chat", course.getId()).contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(requestDTO))) .andExpect(status().isForbidden()); @@ -242,10 +212,8 @@ void shouldReturnForbiddenForStudentAccessingChatEndpoint() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") void shouldReturnForbiddenForTutorAccessingChatEndpoint() throws Exception { - // Given AtlasAgentChatRequestDTO requestDTO = new AtlasAgentChatRequestDTO("Test message", "test-session"); - // When & Then request.performMvcRequest( post("/api/atlas/agent/courses/{courseId}/chat", course.getId()).contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(requestDTO))) .andExpect(status().isForbidden()); @@ -254,10 +222,8 @@ void shouldReturnForbiddenForTutorAccessingChatEndpoint() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void shouldAllowInstructorAccessToChatEndpoint() throws Exception { - // Given AtlasAgentChatRequestDTO requestDTO = new AtlasAgentChatRequestDTO("Test message", "test-session"); - // When & Then request.performMvcRequest( post("/api/atlas/agent/courses/{courseId}/chat", course.getId()).contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(requestDTO))) .andExpect(status().isOk()); diff --git a/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java index ceaa33a23be8..688f93a28bbd 100644 --- a/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java @@ -37,12 +37,11 @@ class AtlasAgentServiceTest { void setUp() { ChatClient chatClient = ChatClient.create(chatModel); // Pass null for ToolCallbackProvider in tests - atlasAgentService = new AtlasAgentService(chatClient, templateService, null); + atlasAgentService = new AtlasAgentService(chatClient, templateService, null, null, null); } @Test void testProcessChatMessage_Success() throws ExecutionException, InterruptedException { - // Given String testMessage = "Help me create competencies for Java programming"; Long courseId = 123L; String sessionId = "course_123"; @@ -51,10 +50,8 @@ void testProcessChatMessage_Success() throws ExecutionException, InterruptedExce when(templateService.render(anyString(), anyMap())).thenReturn("Test system prompt"); when(chatModel.call(any(Prompt.class))).thenReturn(new ChatResponse(List.of(new Generation(new AssistantMessage(expectedResponse))))); - // When CompletableFuture result = atlasAgentService.processChatMessage(testMessage, courseId, sessionId); - // Then assertThat(result).isNotNull(); AgentChatResult chatResult = result.get(); assertThat(chatResult.message()).isEqualTo(expectedResponse); @@ -63,7 +60,6 @@ void testProcessChatMessage_Success() throws ExecutionException, InterruptedExce @Test void testProcessChatMessage_EmptyResponse() throws ExecutionException, InterruptedException { - // Given String testMessage = "Test message"; Long courseId = 456L; String sessionId = "course_456"; @@ -72,10 +68,8 @@ void testProcessChatMessage_EmptyResponse() throws ExecutionException, Interrupt when(templateService.render(anyString(), anyMap())).thenReturn("Test system prompt"); when(chatModel.call(any(Prompt.class))).thenReturn(new ChatResponse(List.of(new Generation(new AssistantMessage(emptyResponse))))); - // When CompletableFuture result = atlasAgentService.processChatMessage(testMessage, courseId, sessionId); - // Then assertThat(result).isNotNull(); AgentChatResult chatResult = result.get(); assertThat(chatResult.message()).isEqualTo("I apologize, but I couldn't generate a response."); @@ -84,7 +78,6 @@ void testProcessChatMessage_EmptyResponse() throws ExecutionException, Interrupt @Test void testProcessChatMessage_NullResponse() throws ExecutionException, InterruptedException { - // Given String testMessage = "Test message"; Long courseId = 789L; String sessionId = "course_789"; @@ -92,10 +85,8 @@ void testProcessChatMessage_NullResponse() throws ExecutionException, Interrupte when(templateService.render(anyString(), anyMap())).thenReturn("Test system prompt"); when(chatModel.call(any(Prompt.class))).thenReturn(new ChatResponse(List.of(new Generation(new AssistantMessage(null))))); - // When CompletableFuture result = atlasAgentService.processChatMessage(testMessage, courseId, sessionId); - // Then assertThat(result).isNotNull(); AgentChatResult chatResult = result.get(); assertThat(chatResult.message()).isEqualTo("I apologize, but I couldn't generate a response."); @@ -104,7 +95,6 @@ void testProcessChatMessage_NullResponse() throws ExecutionException, Interrupte @Test void testProcessChatMessage_WhitespaceOnlyResponse() throws ExecutionException, InterruptedException { - // Given String testMessage = "Test message"; Long courseId = 321L; String sessionId = "course_321"; @@ -113,10 +103,8 @@ void testProcessChatMessage_WhitespaceOnlyResponse() throws ExecutionException, when(templateService.render(anyString(), anyMap())).thenReturn("Test system prompt"); when(chatModel.call(any(Prompt.class))).thenReturn(new ChatResponse(List.of(new Generation(new AssistantMessage(whitespaceResponse))))); - // When CompletableFuture result = atlasAgentService.processChatMessage(testMessage, courseId, sessionId); - // Then assertThat(result).isNotNull(); AgentChatResult chatResult = result.get(); assertThat(chatResult.message()).isEqualTo("I apologize, but I couldn't generate a response."); @@ -125,19 +113,15 @@ void testProcessChatMessage_WhitespaceOnlyResponse() throws ExecutionException, @Test void testProcessChatMessage_ExceptionHandling() throws ExecutionException, InterruptedException { - // Given String testMessage = "Test message"; Long courseId = 654L; String sessionId = "course_654"; when(templateService.render(anyString(), anyMap())).thenReturn("Test system prompt"); - // Mock the ChatModel to throw an exception when(chatModel.call(any(Prompt.class))).thenThrow(new RuntimeException("ChatModel error")); - // When CompletableFuture result = atlasAgentService.processChatMessage(testMessage, courseId, sessionId); - // Then assertThat(result).isNotNull(); AgentChatResult chatResult = result.get(); assertThat(chatResult.message()).isEqualTo("I apologize, but I'm having trouble processing your request right now. Please try again later."); @@ -146,49 +130,22 @@ void testProcessChatMessage_ExceptionHandling() throws ExecutionException, Inter @Test void testIsAvailable_WithValidChatClient() { - // When boolean available = atlasAgentService.isAvailable(); - // Then assertThat(available).isTrue(); } @Test void testIsAvailable_WithNullChatClient() { - // Given - pass null for all optional parameters - AtlasAgentService serviceWithNullClient = new AtlasAgentService(null, templateService, null); + AtlasAgentService serviceWithNullClient = new AtlasAgentService(null, templateService, null, null, null); - // When boolean available = serviceWithNullClient.isAvailable(); - // Then assertThat(available).isFalse(); } - @Test - void testProcessChatMessage_DetectsCompetencyCreation() throws ExecutionException, InterruptedException { - // Given - String testMessage = "Create a competency called 'Java Basics'"; - Long courseId = 999L; - String sessionId = "course_999_user_1"; - String responseWithCreation = "I have successfully created the competency titled 'Java Basics' for your course."; - - when(templateService.render(anyString(), anyMap())).thenReturn("Test system prompt"); - when(chatModel.call(any(Prompt.class))).thenReturn(new ChatResponse(List.of(new Generation(new AssistantMessage(responseWithCreation))))); - - // When - CompletableFuture result = atlasAgentService.processChatMessage(testMessage, courseId, sessionId); - - // Then - assertThat(result).isNotNull(); - AgentChatResult chatResult = result.get(); - assertThat(chatResult.message()).isEqualTo(responseWithCreation); - assertThat(chatResult.competenciesModified()).isTrue(); // Should detect "created" keyword - } - @Test void testConversationIsolation_DifferentUsers() throws ExecutionException, InterruptedException { - // Given - Two different instructors in the same course Long courseId = 123L; String instructor1SessionId = "course_123_user_1"; String instructor2SessionId = "course_123_user_2"; @@ -199,39 +156,16 @@ void testConversationIsolation_DifferentUsers() throws ExecutionException, Inter when(chatModel.call(any(Prompt.class))).thenReturn(new ChatResponse(List.of(new Generation(new AssistantMessage("Response for instructor 1"))))) .thenReturn(new ChatResponse(List.of(new Generation(new AssistantMessage("Response for instructor 2"))))); - // When - Both instructors send messages CompletableFuture result1 = atlasAgentService.processChatMessage(instructor1Message, courseId, instructor1SessionId); CompletableFuture result2 = atlasAgentService.processChatMessage(instructor2Message, courseId, instructor2SessionId); - // Then - Each gets their own response AgentChatResult chatResult1 = result1.get(); AgentChatResult chatResult2 = result2.get(); assertThat(chatResult1.message()).isEqualTo("Response for instructor 1"); assertThat(chatResult2.message()).isEqualTo("Response for instructor 2"); - // Verify sessions are isolated (different session IDs mean different conversation contexts) assertThat(instructor1SessionId).isNotEqualTo(instructor2SessionId); } - @Test - void testProcessChatMessage_DetectsMultipleCreationKeywords() throws ExecutionException, InterruptedException { - // Given - Test different keyword variations for competency modification detection - String testMessage = "Create multiple competencies"; - Long courseId = 888L; - String sessionId = "multi_create_session"; - String responseWithMultipleKeywords = "I successfully created three new competencies for you."; - - when(templateService.render(anyString(), anyMap())).thenReturn("Test system prompt"); - when(chatModel.call(any(Prompt.class))).thenReturn(new ChatResponse(List.of(new Generation(new AssistantMessage(responseWithMultipleKeywords))))); - - // When - CompletableFuture result = atlasAgentService.processChatMessage(testMessage, courseId, sessionId); - - // Then - assertThat(result).isNotNull(); - AgentChatResult chatResult = result.get(); - assertThat(chatResult.message()).isEqualTo(responseWithMultipleKeywords); - assertThat(chatResult.competenciesModified()).isTrue(); // Should detect "created" keyword - } } From a66035e6f80b4d940ca3445b71bfa75bc55f190f Mon Sep 17 00:00:00 2001 From: Yassine Hmidi Date: Thu, 16 Oct 2025 22:49:15 +0100 Subject: [PATCH 21/31] fixed test --- .../cit/aet/artemis/atlas/service/AtlasAgentToolsService.java | 2 ++ .../atlas/manage/agent-chat-modal/agent-chat.service.spec.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java index edf43fc41e24..495f41b41b4a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java @@ -10,6 +10,7 @@ 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; @@ -30,6 +31,7 @@ * Request-scoped to track tool calls per HTTP request. */ @RequestScope +@Lazy @Service @Conditional(AtlasEnabled.class) public class AtlasAgentToolsService { diff --git a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts index 240cca412ec9..927ec91d3170 100644 --- a/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts +++ b/src/main/webapp/app/atlas/manage/agent-chat-modal/agent-chat.service.spec.ts @@ -190,6 +190,7 @@ describe('AgentChatService', () => { const message = 'Test message'; it('should make POST request to correct URL', () => { + mockAccountService.userIdentity = { id: 42, login: 'testuser' }; const expectedUrl = `api/atlas/agent/courses/${courseId}/chat`; service.sendMessage(message, courseId).subscribe(); From 15f1feb041293affd656acb0f88fcf3bd4f3e932 Mon Sep 17 00:00:00 2001 From: Yassine Hmidi Date: Thu, 16 Oct 2025 23:25:56 +0100 Subject: [PATCH 22/31] reverted taxonomy to using enum --- .../atlas/service/AtlasAgentToolsService.java | 23 +++---------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java index 495f41b41b4a..a70fdd666b28 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentToolsService.java @@ -5,8 +5,6 @@ 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; @@ -36,8 +34,6 @@ @Conditional(AtlasEnabled.class) public class AtlasAgentToolsService { - private static final Logger log = LoggerFactory.getLogger(AtlasAgentToolsService.class); - private final ObjectMapper objectMapper; private final CompetencyRepository competencyRepository; @@ -99,12 +95,8 @@ public String getCourseCompetencies(@ToolParam(description = "the ID of the cour @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) { + @ToolParam(description = "the taxonomy level (REMEMBER, UNDERSTAND, APPLY, ANALYZE, EVALUATE, CREATE)") CompetencyTaxonomy taxonomyLevel) { try { - log.debug("Agent tool: Creating competency '{}' for course {}", title, courseId); - - // SET THE FLAG - this.competencyCreated = true; Optional courseOptional = courseRepository.findById(courseId); if (courseOptional.isEmpty()) { @@ -116,19 +108,11 @@ public String createCompetency(@ToolParam(description = "the ID of the course") 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) { - // Invalid taxonomy level - leave taxonomy as null (agent provided invalid value) - } - } + competency.setTaxonomy(taxonomyLevel); Competency savedCompetency = competencyRepository.save(competency); + this.competencyCreated = true; Map competencyData = new LinkedHashMap<>(); competencyData.put("id", savedCompetency.getId()); competencyData.put("title", savedCompetency.getTitle()); @@ -143,7 +127,6 @@ public String createCompetency(@ToolParam(description = "the ID of the course") 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())); } } From 0085fe401bd374b5de873e95beb2714dd031e7da Mon Sep 17 00:00:00 2001 From: Yassine Hmidi Date: Sat, 18 Oct 2025 18:57:25 +0100 Subject: [PATCH 23/31] specified type for options and specs --- .../tum/cit/aet/artemis/atlas/service/AtlasAgentService.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java index d03a27315e1f..4ac472c5da2d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java @@ -5,6 +5,7 @@ import org.springframework.ai.azure.openai.AzureOpenAiChatOptions; import org.springframework.ai.chat.client.ChatClient; +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; @@ -61,9 +62,9 @@ public CompletableFuture processChatMessage(String message, Lon Map variables = Map.of(); // No variables needed for this template String systemPrompt = templateService.render(resourcePath, variables); - var options = AzureOpenAiChatOptions.builder().deploymentName("gpt-4o").temperature(1.0).build(); + AzureOpenAiChatOptions options = AzureOpenAiChatOptions.builder().deploymentName("gpt-4o").temperature(1.0).build(); - var promptSpec = chatClient.prompt().system(systemPrompt).user(String.format("Course ID: %d\n\n%s", courseId, message)).options(options); + 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) { From e30f36992b6402b30b8d9c867c1e09dc189c6bd9 Mon Sep 17 00:00:00 2001 From: Yassine Hmidi Date: Mon, 20 Oct 2025 16:23:59 +0100 Subject: [PATCH 24/31] removed unnecessary autowired annotation --- .../cit/aet/artemis/atlas/service/AtlasAgentService.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java index 4ac472c5da2d..928beede1331 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentService.java @@ -3,13 +3,14 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; +import jakarta.annotation.Nullable; + import org.springframework.ai.azure.openai.AzureOpenAiChatOptions; import org.springframework.ai.chat.client.ChatClient; 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.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; @@ -35,9 +36,8 @@ public class AtlasAgentService { private final AtlasAgentToolsService atlasAgentToolsService; - public AtlasAgentService(@Autowired(required = false) ChatClient chatClient, AtlasPromptTemplateService templateService, - @Autowired(required = false) ToolCallbackProvider toolCallbackProvider, @Autowired(required = false) ChatMemory chatMemory, - @Autowired(required = false) 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; From 2195bc2bfe8823d563455d17dc418f5d0a32af2a Mon Sep 17 00:00:00 2001 From: Yassine Hmidi Date: Tue, 21 Oct 2025 11:41:49 +0100 Subject: [PATCH 25/31] added tools for atlasagenttoolservice --- .../atlas/service/AtlasAgentServiceTest.java | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java index 688f93a28bbd..e94f11f77351 100644 --- a/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java @@ -7,10 +7,13 @@ import static org.mockito.Mockito.when; import java.util.List; +import java.util.Optional; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; @@ -22,6 +25,15 @@ import org.springframework.ai.chat.model.Generation; import org.springframework.ai.chat.prompt.Prompt; +import com.fasterxml.jackson.databind.ObjectMapper; + +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.repository.ExerciseRepository; + @ExtendWith(MockitoExtension.class) class AtlasAgentServiceTest { @@ -168,4 +180,151 @@ void testConversationIsolation_DifferentUsers() throws ExecutionException, Inter assertThat(instructor1SessionId).isNotEqualTo(instructor2SessionId); } + @Nested + class AtlasAgentToolsServiceTests { + + @Mock + private CompetencyRepository competencyRepository; + + @Mock + private CourseRepository courseRepository; + + @Mock + private ExerciseRepository exerciseRepository; + + private AtlasAgentToolsService toolsService; + + @BeforeEach + void setUp() { + ObjectMapper objectMapper = new ObjectMapper(); + toolsService = new AtlasAgentToolsService(objectMapper, competencyRepository, courseRepository, exerciseRepository); + } + + @Test + void testGetCourseDescription_Success() { + Long courseId = 123L; + Course course = new Course(); + course.setDescription("Software Engineering Course"); + + when(courseRepository.findById(courseId)).thenReturn(Optional.of(course)); + + String result = toolsService.getCourseDescription(courseId); + + assertThat(result).isEqualTo("Software Engineering Course"); + } + + @Test + void testGetCourseDescription_NotFound() { + Long courseId = 999L; + + when(courseRepository.findById(courseId)).thenReturn(Optional.empty()); + + String result = toolsService.getCourseDescription(courseId); + + assertThat(result).isEmpty(); + } + + @Test + void testGetCourseCompetencies_Success() { + Long courseId = 123L; + Course course = new Course(); + course.setId(courseId); + + Competency competency = new Competency(); + competency.setId(1L); + competency.setTitle("Java Programming"); + competency.setDescription("Learn Java basics"); + competency.setTaxonomy(CompetencyTaxonomy.APPLY); + + when(courseRepository.findById(courseId)).thenReturn(Optional.of(course)); + when(competencyRepository.findAllByCourseId(courseId)).thenReturn(Set.of(competency)); + + String result = toolsService.getCourseCompetencies(courseId); + + assertThat(result).contains("Java Programming"); + assertThat(result).contains("Learn Java basics"); + assertThat(result).contains("APPLY"); + } + + @Test + void testGetCourseCompetencies_CourseNotFound() { + Long courseId = 999L; + + when(courseRepository.findById(courseId)).thenReturn(Optional.empty()); + + String result = toolsService.getCourseCompetencies(courseId); + + assertThat(result).contains("error"); + assertThat(result).contains("Course not found"); + } + + @Test + void testCreateCompetency_Success() { + Long courseId = 123L; + Course course = new Course(); + course.setId(courseId); + + Competency savedCompetency = new Competency(); + savedCompetency.setId(1L); + savedCompetency.setTitle("Database Design"); + savedCompetency.setDescription("Design relational databases"); + savedCompetency.setTaxonomy(CompetencyTaxonomy.CREATE); + + when(courseRepository.findById(courseId)).thenReturn(Optional.of(course)); + when(competencyRepository.save(any(Competency.class))).thenReturn(savedCompetency); + + String result = toolsService.createCompetency(courseId, "Database Design", "Design relational databases", CompetencyTaxonomy.CREATE); + + assertThat(result).contains("success"); + assertThat(result).contains("Database Design"); + assertThat(result).contains("CREATE"); + assertThat(toolsService.wasCompetencyCreated()).isTrue(); + } + + @Test + void testCreateCompetency_CourseNotFound() { + Long courseId = 999L; + + when(courseRepository.findById(courseId)).thenReturn(Optional.empty()); + + String result = toolsService.createCompetency(courseId, "Test", "Test desc", CompetencyTaxonomy.REMEMBER); + + assertThat(result).contains("error"); + assertThat(result).contains("Course not found"); + assertThat(toolsService.wasCompetencyCreated()).isFalse(); + } + + @Test + void testGetExercisesListed_Success() { + Long courseId = 123L; + Course course = new Course(); + course.setId(courseId); + + when(courseRepository.findById(courseId)).thenReturn(Optional.of(course)); + when(exerciseRepository.findByCourseIds(Set.of(courseId))).thenReturn(Set.of()); + + String result = toolsService.getExercisesListed(courseId); + + assertThat(result).contains("courseId"); + assertThat(result).contains("exercises"); + } + + @Test + void testGetExercisesListed_CourseNotFound() { + Long courseId = 999L; + + when(courseRepository.findById(courseId)).thenReturn(Optional.empty()); + + String result = toolsService.getExercisesListed(courseId); + + assertThat(result).contains("error"); + assertThat(result).contains("Course not found"); + } + + @Test + void testWasCompetencyCreated_InitiallyFalse() { + assertThat(toolsService.wasCompetencyCreated()).isFalse(); + } + } + } From 56fb2b2c8de1233ba362305492b3649028a4647c Mon Sep 17 00:00:00 2001 From: Yassine Hmidi Date: Tue, 21 Oct 2025 11:54:26 +0100 Subject: [PATCH 26/31] removed test repository usage --- .../aet/artemis/atlas/service/AtlasAgentServiceTest.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java index e94f11f77351..e07ff1250475 100644 --- a/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java @@ -31,8 +31,8 @@ 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.repository.ExerciseRepository; +import de.tum.cit.aet.artemis.core.test_repository.CourseTestRepository; +import de.tum.cit.aet.artemis.exercise.repository.ExerciseTestRepository; @ExtendWith(MockitoExtension.class) class AtlasAgentServiceTest { @@ -187,10 +187,10 @@ class AtlasAgentToolsServiceTests { private CompetencyRepository competencyRepository; @Mock - private CourseRepository courseRepository; + private CourseTestRepository courseRepository; @Mock - private ExerciseRepository exerciseRepository; + private ExerciseTestRepository exerciseRepository; private AtlasAgentToolsService toolsService; From 802b12de58b452cb306ea41e370d2976a27c8ddc Mon Sep 17 00:00:00 2001 From: Yassine Hmidi Date: Tue, 21 Oct 2025 12:50:39 +0100 Subject: [PATCH 27/31] added tests for statements --- .../atlas/service/AtlasAgentServiceTest.java | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java index e07ff1250475..28b62933fa89 100644 --- a/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java @@ -180,6 +180,73 @@ void testConversationIsolation_DifferentUsers() throws ExecutionException, Inter assertThat(instructor1SessionId).isNotEqualTo(instructor2SessionId); } + @Test + void testProcessChatMessage_WithCompetencyCreated() throws ExecutionException, InterruptedException { + AtlasAgentToolsService mockToolsService = org.mockito.Mockito.mock(AtlasAgentToolsService.class); + ChatClient chatClient = ChatClient.create(chatModel); + AtlasAgentService serviceWithToolsService = new AtlasAgentService(chatClient, templateService, null, null, mockToolsService); + + String testMessage = "Create a competency"; + Long courseId = 123L; + String sessionId = "session_create_comp"; + String expectedResponse = "Competency created"; + + when(templateService.render(anyString(), anyMap())).thenReturn("Test system prompt"); + when(chatModel.call(any(Prompt.class))).thenReturn(new ChatResponse(List.of(new Generation(new AssistantMessage(expectedResponse))))); + when(mockToolsService.wasCompetencyCreated()).thenReturn(true); + + CompletableFuture result = serviceWithToolsService.processChatMessage(testMessage, courseId, sessionId); + + assertThat(result).isNotNull(); + AgentChatResult chatResult = result.get(); + assertThat(chatResult.message()).isEqualTo(expectedResponse); + assertThat(chatResult.competenciesModified()).isTrue(); + } + + @Test + void testProcessChatMessage_WithCompetencyNotCreated() throws ExecutionException, InterruptedException { + AtlasAgentToolsService mockToolsService = org.mockito.Mockito.mock(AtlasAgentToolsService.class); + ChatClient chatClient = ChatClient.create(chatModel); + AtlasAgentService serviceWithToolsService = new AtlasAgentService(chatClient, templateService, null, null, mockToolsService); + + String testMessage = "Show me competencies"; + Long courseId = 123L; + String sessionId = "session_no_comp_created"; + String expectedResponse = "Here are the competencies"; + + when(templateService.render(anyString(), anyMap())).thenReturn("Test system prompt"); + when(chatModel.call(any(Prompt.class))).thenReturn(new ChatResponse(List.of(new Generation(new AssistantMessage(expectedResponse))))); + when(mockToolsService.wasCompetencyCreated()).thenReturn(false); + + CompletableFuture result = serviceWithToolsService.processChatMessage(testMessage, courseId, sessionId); + + assertThat(result).isNotNull(); + AgentChatResult chatResult = result.get(); + assertThat(chatResult.message()).isEqualTo(expectedResponse); + assertThat(chatResult.competenciesModified()).isFalse(); + } + + @Test + void testIsAvailable_WithException() { + ChatClient chatClient = ChatClient.create(chatModel); + AtlasAgentService service = new AtlasAgentService(chatClient, templateService, null, null, null) { + + @Override + public boolean isAvailable() { + try { + throw new RuntimeException("Simulated error"); + } + catch (Exception e) { + return false; + } + } + }; + + boolean available = service.isAvailable(); + + assertThat(available).isFalse(); + } + @Nested class AtlasAgentToolsServiceTests { @@ -325,6 +392,65 @@ void testGetExercisesListed_CourseNotFound() { void testWasCompetencyCreated_InitiallyFalse() { assertThat(toolsService.wasCompetencyCreated()).isFalse(); } + + @Test + void testGetCourseCompetencies_WithNullTaxonomy() { + Long courseId = 123L; + Course course = new Course(); + course.setId(courseId); + + Competency competency = new Competency(); + competency.setId(1L); + competency.setTitle("No Taxonomy"); + competency.setDescription("Competency without taxonomy"); + competency.setTaxonomy(null); + + when(courseRepository.findById(courseId)).thenReturn(Optional.of(course)); + when(competencyRepository.findAllByCourseId(courseId)).thenReturn(Set.of(competency)); + + String result = toolsService.getCourseCompetencies(courseId); + + assertThat(result).contains("No Taxonomy"); + assertThat(result).contains("Competency without taxonomy"); + assertThat(result).contains("\"taxonomy\":\"\""); + } + + @Test + void testCreateCompetency_WithNullTaxonomy() { + Long courseId = 123L; + Course course = new Course(); + course.setId(courseId); + + Competency savedCompetency = new Competency(); + savedCompetency.setId(1L); + savedCompetency.setTitle("No Taxonomy"); + savedCompetency.setDescription("Test"); + savedCompetency.setTaxonomy(null); + + when(courseRepository.findById(courseId)).thenReturn(Optional.of(course)); + when(competencyRepository.save(any(Competency.class))).thenReturn(savedCompetency); + + String result = toolsService.createCompetency(courseId, "No Taxonomy", "Test", CompetencyTaxonomy.APPLY); + + assertThat(result).contains("success"); + assertThat(result).contains("\"taxonomy\":\"\""); + } + + @Test + void testCreateCompetency_WithException() { + Long courseId = 123L; + Course course = new Course(); + course.setId(courseId); + + when(courseRepository.findById(courseId)).thenReturn(Optional.of(course)); + when(competencyRepository.save(any(Competency.class))).thenThrow(new RuntimeException("Database error")); + + String result = toolsService.createCompetency(courseId, "Test", "Test desc", CompetencyTaxonomy.APPLY); + + assertThat(result).contains("error"); + assertThat(result).contains("Failed to create competency"); + assertThat(toolsService.wasCompetencyCreated()).isFalse(); + } } } From 73bef51937a98e58133a9b99b25a6e30dc63e2b6 Mon Sep 17 00:00:00 2001 From: Yassine Hmidi Date: Tue, 21 Oct 2025 13:57:18 +0100 Subject: [PATCH 28/31] useless test removed --- .../atlas/service/AtlasAgentServiceTest.java | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java index 28b62933fa89..a8724edf3d4c 100644 --- a/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java @@ -226,27 +226,6 @@ void testProcessChatMessage_WithCompetencyNotCreated() throws ExecutionException assertThat(chatResult.competenciesModified()).isFalse(); } - @Test - void testIsAvailable_WithException() { - ChatClient chatClient = ChatClient.create(chatModel); - AtlasAgentService service = new AtlasAgentService(chatClient, templateService, null, null, null) { - - @Override - public boolean isAvailable() { - try { - throw new RuntimeException("Simulated error"); - } - catch (Exception e) { - return false; - } - } - }; - - boolean available = service.isAvailable(); - - assertThat(available).isFalse(); - } - @Nested class AtlasAgentToolsServiceTests { From e42a22319e0343840f2aceeb60015b7879698267 Mon Sep 17 00:00:00 2001 From: Yassine Hmidi Date: Tue, 21 Oct 2025 15:08:43 +0100 Subject: [PATCH 29/31] added test for tools --- .../atlas/service/AtlasAgentServiceTest.java | 49 +++++++++++++++---- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java index a8724edf3d4c..2bc9575295a3 100644 --- a/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java @@ -34,6 +34,7 @@ import de.tum.cit.aet.artemis.core.test_repository.CourseTestRepository; import de.tum.cit.aet.artemis.exercise.repository.ExerciseTestRepository; +@Nested @ExtendWith(MockitoExtension.class) class AtlasAgentServiceTest { @@ -161,23 +162,29 @@ void testConversationIsolation_DifferentUsers() throws ExecutionException, Inter Long courseId = 123L; String instructor1SessionId = "course_123_user_1"; String instructor2SessionId = "course_123_user_2"; - String instructor1Message = "Create competency A"; - String instructor2Message = "Create competency B"; + String instructor1FirstMessage = "Remember my name is Alice"; + String instructor1SecondMessage = "What is my name?"; + String instructor2FirstMessage = "Remember my name is Bob"; + String instructor2SecondMessage = "What is my name?"; when(templateService.render(anyString(), anyMap())).thenReturn("Test system prompt"); - when(chatModel.call(any(Prompt.class))).thenReturn(new ChatResponse(List.of(new Generation(new AssistantMessage("Response for instructor 1"))))) - .thenReturn(new ChatResponse(List.of(new Generation(new AssistantMessage("Response for instructor 2"))))); + // Mock should be set up to verify the prompt contains the right history + when(chatModel.call(any(Prompt.class))).thenReturn(new ChatResponse(List.of(new Generation(new AssistantMessage("Hello Alice"))))) + .thenReturn(new ChatResponse(List.of(new Generation(new AssistantMessage("Hello Bob"))))) + .thenReturn(new ChatResponse(List.of(new Generation(new AssistantMessage("Your name is Alice"))))) + .thenReturn(new ChatResponse(List.of(new Generation(new AssistantMessage("Your name is Bob"))))); - CompletableFuture result1 = atlasAgentService.processChatMessage(instructor1Message, courseId, instructor1SessionId); - CompletableFuture result2 = atlasAgentService.processChatMessage(instructor2Message, courseId, instructor2SessionId); + atlasAgentService.processChatMessage(instructor1FirstMessage, courseId, instructor1SessionId).get(); + atlasAgentService.processChatMessage(instructor2FirstMessage, courseId, instructor2SessionId).get(); + + CompletableFuture result1 = atlasAgentService.processChatMessage(instructor1SecondMessage, courseId, instructor1SessionId); + CompletableFuture result2 = atlasAgentService.processChatMessage(instructor2SecondMessage, courseId, instructor2SessionId); AgentChatResult chatResult1 = result1.get(); AgentChatResult chatResult2 = result2.get(); - assertThat(chatResult1.message()).isEqualTo("Response for instructor 1"); - assertThat(chatResult2.message()).isEqualTo("Response for instructor 2"); - - assertThat(instructor1SessionId).isNotEqualTo(instructor2SessionId); + assertThat(chatResult1.message()).contains("Alice"); + assertThat(chatResult2.message()).contains("Bob"); } @Test @@ -226,6 +233,28 @@ void testProcessChatMessage_WithCompetencyNotCreated() throws ExecutionException assertThat(chatResult.competenciesModified()).isFalse(); } + @Test + void testProcessChatMessage_WithChatMemory() throws ExecutionException, InterruptedException { + org.springframework.ai.chat.memory.ChatMemory mockChatMemory = org.mockito.Mockito.mock(org.springframework.ai.chat.memory.ChatMemory.class); + ChatClient chatClient = ChatClient.create(chatModel); + AtlasAgentService serviceWithChatMemory = new AtlasAgentService(chatClient, templateService, null, mockChatMemory, null); + + String testMessage = "Test message with memory"; + Long courseId = 123L; + String sessionId = "session_with_memory"; + String expectedResponse = "Response with memory context"; + + when(templateService.render(anyString(), anyMap())).thenReturn("Test system prompt"); + when(chatModel.call(any(Prompt.class))).thenReturn(new ChatResponse(List.of(new Generation(new AssistantMessage(expectedResponse))))); + + CompletableFuture result = serviceWithChatMemory.processChatMessage(testMessage, courseId, sessionId); + + assertThat(result).isNotNull(); + AgentChatResult chatResult = result.get(); + assertThat(chatResult.message()).isEqualTo(expectedResponse); + assertThat(chatResult.competenciesModified()).isFalse(); + } + @Nested class AtlasAgentToolsServiceTests { From 61b856a9cd0384693641a4f2981f4127fd98b4a8 Mon Sep 17 00:00:00 2001 From: Yassine Hmidi Date: Tue, 21 Oct 2025 16:17:20 +0100 Subject: [PATCH 30/31] added tests for prompt template --- .../atlas/service/AtlasAgentServiceTest.java | 86 ++++++++++++++----- 1 file changed, 63 insertions(+), 23 deletions(-) diff --git a/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java index 2bc9575295a3..30bb3483dc62 100644 --- a/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java @@ -7,6 +7,7 @@ import static org.mockito.Mockito.when; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; @@ -34,7 +35,6 @@ import de.tum.cit.aet.artemis.core.test_repository.CourseTestRepository; import de.tum.cit.aet.artemis.exercise.repository.ExerciseTestRepository; -@Nested @ExtendWith(MockitoExtension.class) class AtlasAgentServiceTest { @@ -233,28 +233,6 @@ void testProcessChatMessage_WithCompetencyNotCreated() throws ExecutionException assertThat(chatResult.competenciesModified()).isFalse(); } - @Test - void testProcessChatMessage_WithChatMemory() throws ExecutionException, InterruptedException { - org.springframework.ai.chat.memory.ChatMemory mockChatMemory = org.mockito.Mockito.mock(org.springframework.ai.chat.memory.ChatMemory.class); - ChatClient chatClient = ChatClient.create(chatModel); - AtlasAgentService serviceWithChatMemory = new AtlasAgentService(chatClient, templateService, null, mockChatMemory, null); - - String testMessage = "Test message with memory"; - Long courseId = 123L; - String sessionId = "session_with_memory"; - String expectedResponse = "Response with memory context"; - - when(templateService.render(anyString(), anyMap())).thenReturn("Test system prompt"); - when(chatModel.call(any(Prompt.class))).thenReturn(new ChatResponse(List.of(new Generation(new AssistantMessage(expectedResponse))))); - - CompletableFuture result = serviceWithChatMemory.processChatMessage(testMessage, courseId, sessionId); - - assertThat(result).isNotNull(); - AgentChatResult chatResult = result.get(); - assertThat(chatResult.message()).isEqualTo(expectedResponse); - assertThat(chatResult.competenciesModified()).isFalse(); - } - @Nested class AtlasAgentToolsServiceTests { @@ -461,4 +439,66 @@ void testCreateCompetency_WithException() { } } + @Nested + class AtlasPromptTemplateServiceTests { + + private AtlasPromptTemplateService promptTemplateService; + + @BeforeEach + void setUp() { + promptTemplateService = new AtlasPromptTemplateService(); + } + + @Test + void testRender_WithoutVariables() { + String resourcePath = "/prompts/atlas/agent_system_prompt.st"; + Map variables = Map.of(); + + String result = promptTemplateService.render(resourcePath, variables); + + assertThat(result).isNotEmpty(); + assertThat(result).contains("You are the Atlas ↔ Artemis AI Competency Assistant"); + } + + @Test + void testRender_WithVariables() { + String resourcePath = "/prompts/atlas/agent_system_prompt.st"; + Map variables = Map.of("testVar", "testValue", "anotherVar", "anotherValue"); + + String result = promptTemplateService.render(resourcePath, variables); + + assertThat(result).isNotEmpty(); + // Variables loop should execute even if not in template + } + + @Test + void testRender_NonExistentResource() { + String resourcePath = "/prompts/atlas/nonexistent_template.st"; + Map variables = Map.of(); + + org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, () -> promptTemplateService.render(resourcePath, variables)); + } + + @Test + void testRender_VariableSubstitution() { + String resourcePath = "/prompts/atlas/agent_system_prompt.st"; + Map variables = Map.of("var1", "value1", "var2", "value2", "var3", "value3"); + + String result = promptTemplateService.render(resourcePath, variables); + + assertThat(result).isNotEmpty(); + // The variables loop should execute + } + + @Test + void testRender_EmptyVariables() { + String resourcePath = "/prompts/atlas/agent_system_prompt.st"; + Map variables = Map.of(); + + String result = promptTemplateService.render(resourcePath, variables); + + assertThat(result).isNotEmpty(); + } + } + } From adfd0d46c0413fa13d8548cf1d50d9145a89429b Mon Sep 17 00:00:00 2001 From: Yassine Hmidi Date: Tue, 21 Oct 2025 16:43:38 +0100 Subject: [PATCH 31/31] fixed style test --- .../cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java index 30bb3483dc62..597f6d5dec6d 100644 --- a/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/atlas/service/AtlasAgentServiceTest.java @@ -1,6 +1,7 @@ package de.tum.cit.aet.artemis.atlas.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.anyString; @@ -476,7 +477,7 @@ void testRender_NonExistentResource() { String resourcePath = "/prompts/atlas/nonexistent_template.st"; Map variables = Map.of(); - org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, () -> promptTemplateService.render(resourcePath, variables)); + assertThatThrownBy(() -> promptTemplateService.render(resourcePath, variables)).isInstanceOf(RuntimeException.class).hasMessageContaining("Failed to load template"); } @Test