From cae4fb5d4529ab9cb791c20f8397d6594b3030c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kaan=20=C3=87ayl=C4=B1?= <38523756+kaancayli@users.noreply.github.com> Date: Tue, 10 Dec 2024 15:40:27 +0100 Subject: [PATCH] Iris: Enhance student support with proactive assistance (#9558) --- .../competency/CompetencyJolService.java | 13 +- .../repository/SubmissionRepository.java | 9 + .../domain/settings/IrisChatSubSettings.java | 12 + .../domain/settings/event/IrisEventType.java | 9 + .../dto/IrisCombinedChatSubSettingsDTO.java | 2 +- .../IrisCompetencyGenerationService.java | 3 + .../service/pyris/PyrisConnectorService.java | 12 +- .../pyris/PyrisEventProcessingException.java | 11 + .../iris/service/pyris/PyrisEventService.java | 66 ++++ .../service/pyris/PyrisPipelineService.java | 157 ++++++-- .../pyris/UnsupportedPyrisEventException.java | 11 + .../service/pyris/dto/chat/PyrisEventDTO.java | 8 + .../PyrisCourseChatPipelineExecutionDTO.java | 4 +- .../pyris/event/CompetencyJolSetEvent.java | 21 + .../service/pyris/event/NewResultEvent.java | 34 ++ .../iris/service/pyris/event/PyrisEvent.java | 14 + .../session/IrisCourseChatSessionService.java | 7 +- .../IrisExerciseChatSessionService.java | 235 +++++++++++- .../IrisTextExerciseChatSessionService.java | 2 + .../IrisUnsupportedExerciseTypeException.java | 11 + .../service/settings/IrisSettingsService.java | 91 ++++- .../settings/IrisSubSettingsService.java | 31 +- .../web/IrisExerciseChatSessionResource.java | 28 +- .../ProgrammingExerciseRepository.java | 3 + .../service/ProgrammingMessagingService.java | 62 ++- .../changelog/20241120213100_changelog.xml | 12 + .../resources/config/liquibase/master.xml | 1 + .../iris/settings/iris-sub-settings.model.ts | 6 + .../exercise-chatbot-button.component.html | 17 + .../exercise-chatbot-button.component.scss | 65 ++++ .../exercise-chatbot-button.component.ts | 81 +++- src/main/webapp/app/iris/iris-chat.service.ts | 6 + ...-common-sub-settings-update.component.html | 30 ++ ...is-common-sub-settings-update.component.ts | 49 ++- .../content/scss/themes/_dark-variables.scss | 1 + .../scss/themes/_default-variables.scss | 1 + src/main/webapp/i18n/de/iris.json | 16 + src/main/webapp/i18n/en/iris.json | 16 + .../communication/PostingServiceUnitTest.java | 28 +- .../service/SavedPostServiceTest.java | 30 +- .../connector/IrisRequestMockProvider.java | 30 ++ .../iris/AbstractIrisIntegrationTest.java | 1 + .../iris/PyrisConnectorServiceTest.java | 3 +- .../iris/PyrisEventSystemIntegrationTest.java | 363 ++++++++++++++++++ .../settings/IrisSettingsIntegrationTest.java | 62 ++- ...ctSpringIntegrationLocalCILocalVCTest.java | 16 + ...mmon-sub-settings-update.component.spec.ts | 3 +- ...s-course-settings-update.component.spec.ts | 7 +- ...s-global-settings-update.component.spec.ts | 4 + .../iris-settings-update-component.spec.ts | 5 +- .../component/iris/settings/mock-settings.ts | 2 + .../exercise-chatbot-button.component.spec.ts | 13 +- .../mock-jhi-translate-directive.directive.ts | 9 + 53 files changed, 1581 insertions(+), 152 deletions(-) create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/event/IrisEventType.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisEventProcessingException.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisEventService.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/UnsupportedPyrisEventException.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/chat/PyrisEventDTO.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/event/CompetencyJolSetEvent.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/event/NewResultEvent.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/event/PyrisEvent.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisUnsupportedExerciseTypeException.java create mode 100644 src/main/resources/config/liquibase/changelog/20241120213100_changelog.xml create mode 100644 src/test/java/de/tum/cit/aet/artemis/iris/PyrisEventSystemIntegrationTest.java create mode 100644 src/test/javascript/spec/helpers/mocks/directive/mock-jhi-translate-directive.directive.ts diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CompetencyJolService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CompetencyJolService.java index 1011cacaf450..7fa11b6f69b9 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CompetencyJolService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CompetencyJolService.java @@ -23,7 +23,8 @@ import de.tum.cit.aet.artemis.atlas.repository.CompetencyRepository; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.repository.UserRepository; -import de.tum.cit.aet.artemis.iris.service.session.IrisCourseChatSessionService; +import de.tum.cit.aet.artemis.iris.service.pyris.PyrisEventService; +import de.tum.cit.aet.artemis.iris.service.pyris.event.CompetencyJolSetEvent; /** * Service Implementation for managing CompetencyJol. @@ -44,15 +45,15 @@ public class CompetencyJolService { private final UserRepository userRepository; - private final Optional irisCourseChatSessionService; + private final Optional pyrisEventService; public CompetencyJolService(CompetencyJolRepository competencyJolRepository, CompetencyRepository competencyRepository, - CompetencyProgressRepository competencyProgressRepository, UserRepository userRepository, Optional irisCourseChatSessionService) { + CompetencyProgressRepository competencyProgressRepository, UserRepository userRepository, Optional pyrisEventService) { this.competencyJolRepository = competencyJolRepository; this.competencyRepository = competencyRepository; this.competencyProgressRepository = competencyProgressRepository; this.userRepository = userRepository; - this.irisCourseChatSessionService = irisCourseChatSessionService; + this.pyrisEventService = pyrisEventService; } /** @@ -83,10 +84,10 @@ public void setJudgementOfLearning(long competencyId, long userId, short jolValu final var jol = createCompetencyJol(competencyId, userId, jolValue, ZonedDateTime.now(), competencyProgress); competencyJolRepository.save(jol); - irisCourseChatSessionService.ifPresent(service -> { + pyrisEventService.ifPresent(service -> { // Inform Iris so it can send a message to the user try { - service.onJudgementOfLearningSet(jol); + service.trigger(new CompetencyJolSetEvent(jol)); } catch (Exception e) { log.warn("Something went wrong while sending the judgement of learning to Iris", e); diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/repository/SubmissionRepository.java b/src/main/java/de/tum/cit/aet/artemis/exercise/repository/SubmissionRepository.java index 16cac0f54cef..26925f2ccbf4 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/repository/SubmissionRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/repository/SubmissionRepository.java @@ -80,6 +80,15 @@ public interface SubmissionRepository extends ArtemisJpaRepository findAllWithResultsAndAssessorByParticipationId(Long participationId); + /** + * Get all submissions of a participation and eagerly load results ordered by submission date in ascending order + * + * @param participationId the id of the participation + * @return a list of the participation's submissions + */ + @EntityGraph(type = LOAD, attributePaths = { "results" }) + List findAllWithResultsByParticipationIdOrderBySubmissionDateAsc(Long participationId); + /** * Get all submissions with their results by the submission ids * diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisChatSubSettings.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisChatSubSettings.java index e8773c783914..431da3e66337 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisChatSubSettings.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisChatSubSettings.java @@ -32,6 +32,10 @@ public class IrisChatSubSettings extends IrisSubSettings { @Convert(converter = IrisListConverter.class) private SortedSet enabledForCategories = new TreeSet<>(); + @Column(name = "disabled_proactive_events", nullable = false) + @Convert(converter = IrisListConverter.class) + private SortedSet disabledProactiveEvents = new TreeSet<>(); + @Nullable public Integer getRateLimit() { return rateLimit; @@ -57,4 +61,12 @@ public SortedSet getEnabledForCategories() { public void setEnabledForCategories(SortedSet enabledForCategories) { this.enabledForCategories = enabledForCategories; } + + public SortedSet getDisabledProactiveEvents() { + return disabledProactiveEvents; + } + + public void setDisabledProactiveEvents(SortedSet disabledProactiveEvents) { + this.disabledProactiveEvents = disabledProactiveEvents; + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/event/IrisEventType.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/event/IrisEventType.java new file mode 100644 index 000000000000..0830a900705b --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/event/IrisEventType.java @@ -0,0 +1,9 @@ +package de.tum.cit.aet.artemis.iris.domain.settings.event; + +/** + * The type of event that can be triggered by the Iris system. + */ +public enum IrisEventType { + + BUILD_FAILED, PROGRESS_STALLED, JOL +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedChatSubSettingsDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedChatSubSettingsDTO.java index 4f003471a4d7..08bdd8aef230 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedChatSubSettingsDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedChatSubSettingsDTO.java @@ -8,6 +8,6 @@ @JsonInclude(JsonInclude.Include.NON_EMPTY) public record IrisCombinedChatSubSettingsDTO(boolean enabled, Integer rateLimit, Integer rateLimitTimeframeHours, @Nullable SortedSet allowedVariants, - @Nullable String selectedVariant, @Nullable SortedSet enabledForCategories) { + @Nullable String selectedVariant, @Nullable SortedSet enabledForCategories, @Nullable SortedSet disabledProactiveEvents) { } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/IrisCompetencyGenerationService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/IrisCompetencyGenerationService.java index 88906ff80628..cf6abbab3c78 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/IrisCompetencyGenerationService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/IrisCompetencyGenerationService.java @@ -2,6 +2,8 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_IRIS; +import java.util.Optional; + import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; @@ -62,6 +64,7 @@ public void executeCompetencyExtractionPipeline(User user, Course course, String pyrisPipelineService.executePipeline( "competency-extraction", "default", + Optional.empty(), pyrisJobService.createTokenForJob(token -> new CompetencyExtractionJob(token, course.getId(), user.getId())), executionDto -> new PyrisCompetencyExtractionPipelineExecutionDTO(executionDto, courseDescription, currentCompetencies, CompetencyTaxonomy.values(), 5), stages -> websocketService.send(user.getLogin(), websocketTopic(course.getId()), new PyrisCompetencyStatusUpdateDTO(stages, null, null)) diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisConnectorService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisConnectorService.java index 911428b5b176..e29999073eb9 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisConnectorService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisConnectorService.java @@ -6,6 +6,7 @@ import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.List; +import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -73,13 +74,13 @@ public List getOfferedVariants(IrisSubSettingsType feature) thr try { var response = restTemplate.getForEntity(pyrisUrl + "/api/v1/pipelines/" + feature.name() + "/variants", PyrisVariantDTO[].class); if (!response.getStatusCode().is2xxSuccessful() || !response.hasBody()) { - throw new PyrisConnectorException("Could not fetch offered models"); + throw new PyrisConnectorException("Could not fetch offered variants"); } return Arrays.asList(response.getBody()); } catch (HttpStatusCodeException e) { - log.error("Failed to fetch offered models from Pyris", e); - throw new PyrisConnectorException("Could not fetch offered models"); + log.error("Failed to fetch offered variants from Pyris", e); + throw new PyrisConnectorException("Could not fetch offered variants"); } } @@ -89,9 +90,12 @@ public List getOfferedVariants(IrisSubSettingsType feature) thr * @param feature The feature name of the pipeline to execute * @param variant The variant of the feature to execute * @param executionDTO The DTO sent as a body for the execution + * @param event The event to be sent as a query parameter, if the pipeline is getting executed due to an event */ - public void executePipeline(String feature, String variant, Object executionDTO) { + public void executePipeline(String feature, String variant, Object executionDTO, Optional event) { var endpoint = "/api/v1/pipelines/" + feature + "/" + variant + "/run"; + // Add event query parameter if present + endpoint += event.map(e -> "?event=" + e).orElse(""); try { restTemplate.postForEntity(pyrisUrl + endpoint, objectMapper.valueToTree(executionDTO), Void.class); } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisEventProcessingException.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisEventProcessingException.java new file mode 100644 index 000000000000..546f9855c5ad --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisEventProcessingException.java @@ -0,0 +1,11 @@ +package de.tum.cit.aet.artemis.iris.service.pyris; + +/** + * Exception thrown when an error occurs during Pyris event processing. + */ +public class PyrisEventProcessingException extends RuntimeException { + + public PyrisEventProcessingException(String message) { + super(message); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisEventService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisEventService.java new file mode 100644 index 000000000000..58f0b8a069b8 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisEventService.java @@ -0,0 +1,66 @@ +package de.tum.cit.aet.artemis.iris.service.pyris; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_IRIS; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import de.tum.cit.aet.artemis.iris.domain.session.IrisChatSession; +import de.tum.cit.aet.artemis.iris.service.pyris.event.CompetencyJolSetEvent; +import de.tum.cit.aet.artemis.iris.service.pyris.event.NewResultEvent; +import de.tum.cit.aet.artemis.iris.service.pyris.event.PyrisEvent; +import de.tum.cit.aet.artemis.iris.service.session.AbstractIrisChatSessionService; +import de.tum.cit.aet.artemis.iris.service.session.IrisCourseChatSessionService; +import de.tum.cit.aet.artemis.iris.service.session.IrisExerciseChatSessionService; + +/** + * Service to handle Pyris events. + */ +@Service +@Profile(PROFILE_IRIS) +public class PyrisEventService { + + private static final Logger log = LoggerFactory.getLogger(PyrisEventService.class); + + private final IrisCourseChatSessionService irisCourseChatSessionService; + + private final IrisExerciseChatSessionService irisExerciseChatSessionService; + + public PyrisEventService(IrisCourseChatSessionService irisCourseChatSessionService, IrisExerciseChatSessionService irisExerciseChatSessionService) { + this.irisCourseChatSessionService = irisCourseChatSessionService; + this.irisExerciseChatSessionService = irisExerciseChatSessionService; + } + + /** + * Triggers a Pyris pipeline based on the received {@link PyrisEvent}. + * + * @param event The event object received to trigger the matching pipeline + * @throws UnsupportedPyrisEventException if the event is not supported + * + * @see PyrisEvent + */ + public void trigger(PyrisEvent, ?> event) { + log.debug("Starting to process event of type: {}", event.getClass().getSimpleName()); + try { + switch (event) { + case CompetencyJolSetEvent competencyJolSetEvent -> { + log.info("Processing CompetencyJolSetEvent: {}", competencyJolSetEvent); + competencyJolSetEvent.handleEvent(irisCourseChatSessionService); + log.debug("Successfully processed CompetencyJolSetEvent"); + } + case NewResultEvent newResultEvent -> { + log.info("Processing NewResultEvent: {}", newResultEvent); + newResultEvent.handleEvent(irisExerciseChatSessionService); + log.debug("Successfully processed NewResultEvent"); + } + default -> throw new UnsupportedPyrisEventException("Unsupported event type: " + event.getClass().getSimpleName()); + } + } + catch (Exception e) { + log.error("Failed to process event: {}", event, e); + throw e; + } + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisPipelineService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisPipelineService.java index c8851341e29e..62d7f4cc99dc 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisPipelineService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisPipelineService.java @@ -2,6 +2,8 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_IRIS; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -22,6 +24,7 @@ import de.tum.cit.aet.artemis.atlas.dto.CompetencyJolDTO; 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.domain.participation.StudentParticipation; import de.tum.cit.aet.artemis.exercise.repository.StudentParticipationRepository; import de.tum.cit.aet.artemis.iris.domain.session.IrisCourseChatSession; @@ -29,9 +32,11 @@ import de.tum.cit.aet.artemis.iris.exception.IrisException; import de.tum.cit.aet.artemis.iris.service.pyris.dto.PyrisPipelineExecutionDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.PyrisPipelineExecutionSettingsDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.chat.PyrisEventDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.chat.course.PyrisCourseChatPipelineExecutionDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.chat.exercise.PyrisExerciseChatPipelineExecutionDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.data.PyrisCourseDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.data.PyrisExerciseWithStudentSubmissionsDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.data.PyrisExtendedCourseDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.data.PyrisUserDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.status.PyrisStageDTO; @@ -89,11 +94,13 @@ public PyrisPipelineService(PyrisConnectorService pyrisConnectorService, PyrisJo * * @param name the name of the pipeline to be executed * @param variant the variant of the pipeline + * @param event an optional event variant that can be used to trigger specific event of the given pipeline * @param jobToken a unique job token for tracking the pipeline execution * @param dtoMapper a function to create the concrete DTO type for this pipeline from the base DTO * @param statusUpdater a consumer to update the status of the pipeline execution */ - public void executePipeline(String name, String variant, String jobToken, Function dtoMapper, Consumer> statusUpdater) { + public void executePipeline(String name, String variant, Optional event, String jobToken, Function dtoMapper, + Consumer> statusUpdater) { // Define the preparation stages of pipeline execution with their initial states // There will be more stages added in Pyris later var preparing = new PyrisStageDTO("Preparing", 10, null, null); @@ -111,7 +118,7 @@ public void executePipeline(String name, String variant, String jobToken, Functi try { // Execute the pipeline using the connector service - pyrisConnectorService.executePipeline(name, variant, pipelineDto); + pyrisConnectorService.executePipeline(name, variant, pipelineDto, event); } catch (PyrisConnectorException | IrisException e) { log.error("Failed to execute {} pipeline", name, e); @@ -136,13 +143,16 @@ public void executePipeline(String name, String variant, String jobToken, Functi * @param latestSubmission the latest submission of the student * @param exercise the programming exercise * @param session the chat session + * @param eventVariant if this function triggers a pipeline execution due to a specific event, this is the used event variant * @see PyrisPipelineService#executePipeline for more details on the pipeline execution process. */ - public void executeExerciseChatPipeline(String variant, Optional latestSubmission, ProgrammingExercise exercise, IrisExerciseChatSession session) { + public void executeExerciseChatPipeline(String variant, Optional latestSubmission, ProgrammingExercise exercise, IrisExerciseChatSession session, + Optional eventVariant) { // @formatter:off executePipeline( "tutor-chat", // TODO: Rename this to 'exercise-chat' with next breaking Pyris version variant, + eventVariant, pyrisJobService.addExerciseChatJob(exercise.getCourseViaExerciseGroupOrCourseMember().getId(), exercise.getId(), session.getId()), executionDto -> { var course = exercise.getCourseViaExerciseGroupOrCourseMember(); @@ -166,39 +176,63 @@ public void executeExerciseChatPipeline(String variant, Optional + * - Event-specific data if this is due to a specific event * * @param variant the variant of the pipeline * @param session the chat session - * @param competencyJol if this is due to a JoL set event, this must be the newly created competencyJoL - * @see PyrisPipelineService#executePipeline for more details on the pipeline execution process. + * @param eventObject if this function triggers a pipeline execution due to a specific event, this object is the event payload + * @param eventDtoClass the class of the DTO that should be generated from the object + * @param the type of the object + * @param the type of the DTO */ - public void executeCourseChatPipeline(String variant, IrisCourseChatSession session, CompetencyJol competencyJol) { - // @formatter:off + private void executeCourseChatPipeline(String variant, IrisCourseChatSession session, T eventObject, Class eventDtoClass, Optional eventVariant) { var courseId = session.getCourse().getId(); var studentId = session.getUser().getId(); + // @formatter:off executePipeline( - "course-chat", - variant, - pyrisJobService.addCourseChatJob(courseId, session.getId()), - executionDto -> { - var fullCourse = loadCourseWithParticipationOfStudent(courseId, studentId); - return new PyrisCourseChatPipelineExecutionDTO( - PyrisExtendedCourseDTO.of(fullCourse), - learningMetricsApi.getStudentCourseMetrics(session.getUser().getId(), courseId), - competencyJol == null ? null : CompetencyJolDTO.of(competencyJol), - pyrisDTOService.toPyrisMessageDTOList(session.getMessages()), - new PyrisUserDTO(session.getUser()), - executionDto.settings(), // flatten the execution dto here - executionDto.initialStages() - ); - }, - stages -> irisChatWebsocketService.sendStatusUpdate(session, stages) + "course-chat", + variant, + eventVariant, + pyrisJobService.addCourseChatJob(courseId, session.getId()), executionDto -> { + var fullCourse = loadCourseWithParticipationOfStudent(courseId, studentId); + return new PyrisCourseChatPipelineExecutionDTO<>( + PyrisExtendedCourseDTO.of(fullCourse), + learningMetricsApi.getStudentCourseMetrics(session.getUser().getId(), courseId), + generateEventPayloadFromObjectType(eventDtoClass, eventObject), // get the event payload DTO + pyrisDTOService.toPyrisMessageDTOList(session.getMessages()), + new PyrisUserDTO(session.getUser()), + executionDto.settings(), // flatten the execution dto here + executionDto.initialStages() + ); + }, + stages -> irisChatWebsocketService.sendStatusUpdate(session, stages) ); // @formatter:on } + /** + * Execute the course chat pipeline for the given session. + * It provides specific data for the course chat pipeline, including: + * - The full course with the participation of the student + * - The metrics of the student in the course + * - The competency JoL if this is due to a JoL set event + *

+ * + * @param variant the variant of the pipeline + * @param session the chat session + * @param object if this function triggers a pipeline execution due to a specific event, this object is the event payload + * @see PyrisPipelineService#executePipeline for more details on the pipeline execution process. + */ + public void executeCourseChatPipeline(String variant, IrisCourseChatSession session, Object object) { + log.debug("Executing course chat pipeline variant {} with object {}", variant, object); + switch (object) { + case null -> executeCourseChatPipeline(variant, session, null, null, Optional.empty()); + case CompetencyJol competencyJol -> executeCourseChatPipeline(variant, session, competencyJol, CompetencyJolDTO.class, Optional.of("jol")); + case Exercise exercise -> executeCourseChatPipeline(variant, session, exercise, PyrisExerciseWithStudentSubmissionsDTO.class, Optional.empty()); + default -> throw new UnsupportedOperationException("Unsupported Pyris event payload type: " + object); + } + } + /** * Load the course with the participation of the student and set the participations on the exercises. *

@@ -225,4 +259,77 @@ private Course loadCourseWithParticipationOfStudent(long courseId, long studentI return course; } + + /** + * Generate an PyrisEventDTO from an object type by invoking the 'of' method of the DTO class. + * The 'of' method must be a static method that accepts the object type as argument and returns a subclass of PyrisEventDTO. + *

+ * This method is used to generate DTOs from object types that are not known at compile time. + * It is used to generate DTOs from Pyris event objects that are passed to the chat pipeline. + * The DTO classes must have a static 'of' method that accepts the object type as argument. + * The return type of the 'of' method must be a subclass of PyrisEventDTO. + *

+ * + * @param dtoClass the class of the DTO that should be generated + * @param object the object to generate the DTO from + * @param the type of the object + * @param the type of the DTO + * @return PyrisEventDTO the generated DTO + */ + private PyrisEventDTO generateEventPayloadFromObjectType(Class dtoClass, T object) { + + if (object == null) { + return null; + } + // Get the 'of' method from the DTO class + Method ofMethod = getOfMethod(dtoClass, object); + + // Invoke the 'of' method with the object as argument + try { + Object result = ofMethod.invoke(null, object); + return new PyrisEventDTO<>(dtoClass.cast(result), object.getClass().getSimpleName()); + } + catch (IllegalArgumentException e) { + throw new UnsupportedOperationException("The 'of' method's parameter type doesn't match the provided object", e); + } + catch (IllegalAccessException e) { + throw new UnsupportedOperationException("The 'of' method is not accessible", e); + } + catch (InvocationTargetException e) { + throw new UnsupportedOperationException("The 'of' method threw an exception", e.getCause()); + } + catch (ClassCastException e) { + throw new UnsupportedOperationException("The 'of' method's return type is not compatible with " + dtoClass.getSimpleName(), e); + } + } + + /** + * Get the 'of' method from the DTO class that accepts the object type as argument. + * + * @param dtoClass the class of the DTO + * @param object the object to generate the DTO from + * @param the type of the object + * @param the type of the DTO + * @return Method the 'of' method + */ + private static Method getOfMethod(Class dtoClass, T object) { + Method ofMethod = null; + Class currentClass = object.getClass(); + + // Traverse up the class hierarchy + while (currentClass != null && ofMethod == null) { + for (Method method : dtoClass.getMethods()) { + if (method.getName().equals("of") && method.getParameterCount() == 1 && method.getParameters()[0].getType().isAssignableFrom(currentClass)) { + ofMethod = method; + break; + } + } + currentClass = currentClass.getSuperclass(); + } + + if (ofMethod == null) { + throw new UnsupportedOperationException("Failed to find suitable 'of' method in " + dtoClass.getSimpleName() + " for " + object.getClass().getSimpleName()); + } + return ofMethod; + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/UnsupportedPyrisEventException.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/UnsupportedPyrisEventException.java new file mode 100644 index 000000000000..d02e149d69a5 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/UnsupportedPyrisEventException.java @@ -0,0 +1,11 @@ +package de.tum.cit.aet.artemis.iris.service.pyris; + +/** + * Exception thrown when an unsupported Pyris event is encountered. + */ +public class UnsupportedPyrisEventException extends RuntimeException { + + public UnsupportedPyrisEventException(String message) { + super(message); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/chat/PyrisEventDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/chat/PyrisEventDTO.java new file mode 100644 index 000000000000..7976f7e6b853 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/chat/PyrisEventDTO.java @@ -0,0 +1,8 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.dto.chat; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record PyrisEventDTO(T event, String eventType) { + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/chat/course/PyrisCourseChatPipelineExecutionDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/chat/course/PyrisCourseChatPipelineExecutionDTO.java index a27dba39442c..87dcf5268f38 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/chat/course/PyrisCourseChatPipelineExecutionDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/chat/course/PyrisCourseChatPipelineExecutionDTO.java @@ -4,15 +4,15 @@ import com.fasterxml.jackson.annotation.JsonInclude; -import de.tum.cit.aet.artemis.atlas.dto.CompetencyJolDTO; import de.tum.cit.aet.artemis.atlas.dto.metrics.StudentMetricsDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.PyrisPipelineExecutionSettingsDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.chat.PyrisEventDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.data.PyrisExtendedCourseDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.data.PyrisMessageDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.data.PyrisUserDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.status.PyrisStageDTO; @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record PyrisCourseChatPipelineExecutionDTO(PyrisExtendedCourseDTO course, StudentMetricsDTO metrics, CompetencyJolDTO competencyJol, List chatHistory, +public record PyrisCourseChatPipelineExecutionDTO(PyrisExtendedCourseDTO course, StudentMetricsDTO metrics, PyrisEventDTO eventPayload, List chatHistory, PyrisUserDTO user, PyrisPipelineExecutionSettingsDTO settings, List initialStages) { } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/event/CompetencyJolSetEvent.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/event/CompetencyJolSetEvent.java new file mode 100644 index 000000000000..9ee7448811b4 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/event/CompetencyJolSetEvent.java @@ -0,0 +1,21 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.event; + +import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyJol; +import de.tum.cit.aet.artemis.iris.service.session.IrisCourseChatSessionService; + +public class CompetencyJolSetEvent extends PyrisEvent { + + private final CompetencyJol eventObject; + + public CompetencyJolSetEvent(CompetencyJol eventObject) { + if (eventObject == null) { + throw new IllegalArgumentException("Event object cannot be null"); + } + this.eventObject = eventObject; + } + + @Override + public void handleEvent(IrisCourseChatSessionService service) { + service.onJudgementOfLearningSet(eventObject); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/event/NewResultEvent.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/event/NewResultEvent.java new file mode 100644 index 000000000000..27516dff283a --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/event/NewResultEvent.java @@ -0,0 +1,34 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.event; + +import de.tum.cit.aet.artemis.assessment.domain.Result; +import de.tum.cit.aet.artemis.iris.service.session.IrisExerciseChatSessionService; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingSubmission; + +public class NewResultEvent extends PyrisEvent { + + private final Result eventObject; + + public NewResultEvent(Result eventObject) { + if (eventObject == null) { + throw new IllegalArgumentException("Event object cannot be null"); + } + this.eventObject = eventObject; + } + + @Override + public void handleEvent(IrisExerciseChatSessionService service) { + if (service == null) { + throw new IllegalArgumentException("Service cannot be null"); + } + var submission = eventObject.getSubmission(); + // We only care about programming submissions + if (submission instanceof ProgrammingSubmission programmingSubmission) { + if (programmingSubmission.isBuildFailed()) { + service.onBuildFailure(eventObject); + } + else { + service.onNewResult(eventObject); + } + } + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/event/PyrisEvent.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/event/PyrisEvent.java new file mode 100644 index 000000000000..0f4a723653d6 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/event/PyrisEvent.java @@ -0,0 +1,14 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.event; + +import de.tum.cit.aet.artemis.iris.domain.session.IrisChatSession; +import de.tum.cit.aet.artemis.iris.service.session.AbstractIrisChatSessionService; + +public abstract class PyrisEvent, T> { + + /** + * Handles the event using the given service. + * + * @param service The service to handle the event for + */ + public abstract void handleEvent(S service); +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisCourseChatSessionService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisCourseChatSessionService.java index 7e6693991430..f6a97190142c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisCourseChatSessionService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisCourseChatSessionService.java @@ -115,10 +115,9 @@ public void requestAndHandleResponse(IrisCourseChatSession session) { requestAndHandleResponse(session, variant, null); } - private void requestAndHandleResponse(IrisCourseChatSession session, String variant, CompetencyJol competencyJol) { + private void requestAndHandleResponse(IrisCourseChatSession session, String variant, Object object) { var chatSession = (IrisCourseChatSession) irisSessionRepository.findByIdWithMessagesAndContents(session.getId()); - - pyrisPipelineService.executeCourseChatPipeline(variant, chatSession, competencyJol); + pyrisPipelineService.executeCourseChatPipeline(variant, chatSession, object); } @Override @@ -140,7 +139,7 @@ public void onJudgementOfLearningSet(CompetencyJol competencyJol) { var user = competencyJol.getUser(); user.hasAcceptedIrisElseThrow(); var session = getCurrentSessionOrCreateIfNotExistsInternal(course, user, false); - CompletableFuture.runAsync(() -> requestAndHandleResponse(session, "jol", competencyJol)); + CompletableFuture.runAsync(() -> requestAndHandleResponse(session, "default", competencyJol)); } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisExerciseChatSessionService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisExerciseChatSessionService.java index 20aa684e534a..d422970401e0 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisExerciseChatSessionService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisExerciseChatSessionService.java @@ -5,25 +5,36 @@ import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.stream.IntStream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Profile; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import com.fasterxml.jackson.databind.ObjectMapper; +import de.tum.cit.aet.artemis.assessment.domain.Result; import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.core.exception.AccessForbiddenException; import de.tum.cit.aet.artemis.core.exception.ConflictException; import de.tum.cit.aet.artemis.core.security.Role; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; import de.tum.cit.aet.artemis.core.service.LLMTokenUsageService; +import de.tum.cit.aet.artemis.exercise.domain.Exercise; import de.tum.cit.aet.artemis.exercise.domain.Submission; +import de.tum.cit.aet.artemis.exercise.repository.SubmissionRepository; import de.tum.cit.aet.artemis.iris.domain.message.IrisMessage; import de.tum.cit.aet.artemis.iris.domain.session.IrisExerciseChatSession; import de.tum.cit.aet.artemis.iris.domain.settings.IrisSubSettingsType; +import de.tum.cit.aet.artemis.iris.domain.settings.event.IrisEventType; +import de.tum.cit.aet.artemis.iris.repository.IrisExerciseChatSessionRepository; import de.tum.cit.aet.artemis.iris.repository.IrisSessionRepository; import de.tum.cit.aet.artemis.iris.service.IrisMessageService; import de.tum.cit.aet.artemis.iris.service.IrisRateLimitService; +import de.tum.cit.aet.artemis.iris.service.pyris.PyrisEventProcessingException; import de.tum.cit.aet.artemis.iris.service.pyris.PyrisPipelineService; import de.tum.cit.aet.artemis.iris.service.settings.IrisSettingsService; import de.tum.cit.aet.artemis.iris.service.websocket.IrisChatWebsocketService; @@ -41,6 +52,8 @@ @Profile(PROFILE_IRIS) public class IrisExerciseChatSessionService extends AbstractIrisChatSessionService implements IrisRateLimitedFeatureInterface { + private static final Logger log = LoggerFactory.getLogger(IrisExerciseChatSessionService.class); + private final IrisSettingsService irisSettingsService; private final IrisChatWebsocketService irisChatWebsocketService; @@ -59,11 +72,15 @@ public class IrisExerciseChatSessionService extends AbstractIrisChatSessionServi private final ProgrammingExerciseRepository programmingExerciseRepository; + private final IrisExerciseChatSessionRepository irisExerciseChatSessionRepository; + + private final SubmissionRepository submissionRepository; + public IrisExerciseChatSessionService(IrisMessageService irisMessageService, LLMTokenUsageService llmTokenUsageService, IrisSettingsService irisSettingsService, IrisChatWebsocketService irisChatWebsocketService, AuthorizationCheckService authCheckService, IrisSessionRepository irisSessionRepository, ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository, ProgrammingSubmissionRepository programmingSubmissionRepository, IrisRateLimitService rateLimitService, PyrisPipelineService pyrisPipelineService, ProgrammingExerciseRepository programmingExerciseRepository, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, IrisExerciseChatSessionRepository irisExerciseChatSessionRepository, SubmissionRepository submissionRepository) { super(irisSessionRepository, objectMapper, irisMessageService, irisChatWebsocketService, llmTokenUsageService); this.irisSettingsService = irisSettingsService; this.irisChatWebsocketService = irisChatWebsocketService; @@ -74,6 +91,8 @@ public IrisExerciseChatSessionService(IrisMessageService irisMessageService, LLM this.rateLimitService = rateLimitService; this.pyrisPipelineService = pyrisPipelineService; this.programmingExerciseRepository = programmingExerciseRepository; + this.irisExerciseChatSessionRepository = irisExerciseChatSessionRepository; + this.submissionRepository = submissionRepository; } /** @@ -83,10 +102,9 @@ public IrisExerciseChatSessionService(IrisMessageService irisMessageService, LLM * @param user The user the session belongs to * @return The created session */ + // TODO: This function is only used in tests. Replace with createSession once the tests are refactored. public IrisExerciseChatSession createChatSessionForProgrammingExercise(ProgrammingExercise exercise, User user) { - if (exercise.isExamExercise()) { - throw new ConflictException("Iris is not supported for exam exercises", "Iris", "irisExamExercise"); - } + checkIfExamExercise(exercise); return irisSessionRepository.save(new IrisExerciseChatSession(exercise, user)); } @@ -128,12 +146,23 @@ public void checkRateLimit(User user) { /** * Sends all messages of the session to an LLM and handles the response by saving the message - * and sending it to the student via the Websocket. + * and sending it to the student via the Websocket. Uses the default pipeline variant. * * @param session The chat session to send to the LLM */ @Override public void requestAndHandleResponse(IrisExerciseChatSession session) { + requestAndHandleResponse(session, Optional.empty()); + } + + /** + * Sends all messages of the session to an LLM and handles the response by saving the message + * and sending it to the student via the Websocket. + * + * @param session The chat session to send to the LLM + * @param event The event to trigger on Pyris side + */ + public void requestAndHandleResponse(IrisExerciseChatSession session, Optional event) { var chatSession = (IrisExerciseChatSession) irisSessionRepository.findByIdWithMessagesAndContents(session.getId()); if (chatSession.getExercise().isExamExercise()) { throw new ConflictException("Iris is not supported for exam exercises", "Iris", "irisExamExercise"); @@ -142,7 +171,83 @@ public void requestAndHandleResponse(IrisExerciseChatSession session) { var latestSubmission = getLatestSubmissionIfExists(exercise, chatSession.getUser()); var variant = irisSettingsService.getCombinedIrisSettingsFor(session.getExercise(), false).irisChatSettings().selectedVariant(); - pyrisPipelineService.executeExerciseChatPipeline(variant, latestSubmission, exercise, chatSession); + pyrisPipelineService.executeExerciseChatPipeline(variant, latestSubmission, exercise, chatSession, event); + } + + /** + * Handles the build failure event by sending a message to the student via Iris. + * + * @param result The result of the submission + */ + public void onBuildFailure(Result result) { + var submission = result.getSubmission(); + if (submission instanceof ProgrammingSubmission programmingSubmission) { + var participation = programmingSubmission.getParticipation(); + if (!(participation instanceof ProgrammingExerciseStudentParticipation studentParticipation)) { + return; + } + var exercise = validateExercise(participation.getExercise()); + + irisSettingsService.isActivatedForElseThrow(IrisEventType.BUILD_FAILED, exercise); + + var participant = studentParticipation.getParticipant(); + if (participant instanceof User user) { + var session = getCurrentSessionOrCreateIfNotExistsInternal(exercise, user, false); + log.info("Build failed for user {}", user.getName()); + CompletableFuture.runAsync(() -> requestAndHandleResponse(session, Optional.of(IrisEventType.BUILD_FAILED.name().toLowerCase()))); + } + else { + throw new PyrisEventProcessingException("Build failure event is not supported for team participations"); + } + } + } + + /** + * Informs Iris about a progress stall event, if the student has not improved their in the last 3 submissions. + * + * @param result The result of the submission + */ + public void onNewResult(Result result) { + var participation = result.getSubmission().getParticipation(); + if (!(participation instanceof ProgrammingExerciseStudentParticipation studentParticipation)) { + return; + } + + var exercise = validateExercise(participation.getExercise()); + + irisSettingsService.isActivatedForElseThrow(IrisEventType.PROGRESS_STALLED, exercise); + + var recentSubmissions = submissionRepository.findAllWithResultsByParticipationIdOrderBySubmissionDateAsc(studentParticipation.getId()); + + double successThreshold = 100.0; // TODO: Retrieve configuration from Iris settings + + // Check if the user has already successfully submitted before + var successfulSubmission = recentSubmissions.stream() + .anyMatch(submission -> submission.getLatestResult() != null && submission.getLatestResult().getScore() == successThreshold); + if (!successfulSubmission && recentSubmissions.size() >= 3) { + var listOfScores = recentSubmissions.stream().map(Submission::getLatestResult).filter(Objects::nonNull).map(Result::getScore).toList(); + + // Check if the student needs intervention based on their recent score trajectory + var needsIntervention = needsIntervention(listOfScores); + if (needsIntervention) { + log.info("Scores in the last 3 submissions did not improve for user {}", studentParticipation.getParticipant().getName()); + var participant = ((ProgrammingExerciseStudentParticipation) participation).getParticipant(); + if (participant instanceof User user) { + var session = getCurrentSessionOrCreateIfNotExistsInternal(exercise, user, false); + CompletableFuture.runAsync(() -> requestAndHandleResponse(session, Optional.of(IrisEventType.PROGRESS_STALLED.name().toLowerCase()))); + } + else { + throw new PyrisEventProcessingException("Progress stalled event is not supported for team participations"); + } + } + } + else { + log.info("Submission was not successful for user {}", studentParticipation.getParticipant().getName()); + if (successfulSubmission) { + log.info("User {} has already successfully submitted before, so we do not inform Iris about the submission failure", + studentParticipation.getParticipant().getName()); + } + } } private Optional getLatestSubmissionIfExists(ProgrammingExercise exercise, User user) { @@ -161,9 +266,127 @@ private Optional getLatestSubmissionIfExists(ProgrammingE .flatMap(sub -> programmingSubmissionRepository.findWithEagerResultsAndFeedbacksAndBuildLogsById(sub.getId())); } + /** + * Checks if there's overall improvement in the given interval [i, j] of the list. + * + * @param scores The list of scores. + * @param i The starting index of the interval (inclusive). + * @param j The ending index of the interval (inclusive). + * @return true if there's overall improvement (last score > first score), false otherwise. + */ + private boolean hasOverallImprovement(List scores, int i, int j) { + if (i >= j || i < 0 || j >= scores.size()) { + throw new IllegalArgumentException("Invalid interval"); + } + + return scores.get(j) > scores.get(i) && IntStream.range(i, j).allMatch(index -> scores.get(index) <= scores.get(index + 1)); + } + + /** + * Checks if the student needs intervention based on their recent score trajectory. + * + * @param scores The list of all scores for the student. + * @return true if intervention is needed, false otherwise. + */ + private boolean needsIntervention(List scores) { + int intervalSize = 3; // TODO: Retrieve configuration from Iris settings + if (scores.size() < intervalSize) { + return false; // Not enough data to make a decision + } + + int lastIndex = scores.size() - 1; + int startIndex = lastIndex - intervalSize + 1; + + return !hasOverallImprovement(scores, startIndex, lastIndex); + } + + /** + * Gets the current Iris session for the exercise and user. + * If no session exists or if the last session is from a different day, a new one is created. + * + * @param exercise Programming exercise to get the session for + * @param user The user to get the session for + * @param sendInitialMessageIfCreated Whether to send an initial message from Iris if a new session is created + * @return The current Iris session + */ + public IrisExerciseChatSession getCurrentSessionOrCreateIfNotExists(ProgrammingExercise exercise, User user, boolean sendInitialMessageIfCreated) { + user.hasAcceptedIrisElseThrow(); + irisSettingsService.isEnabledForElseThrow(IrisSubSettingsType.CHAT, exercise); + return getCurrentSessionOrCreateIfNotExistsInternal(exercise, user, sendInitialMessageIfCreated); + } + + private IrisExerciseChatSession getCurrentSessionOrCreateIfNotExistsInternal(ProgrammingExercise exercise, User user, boolean sendInitialMessageIfCreated) { + var sessionOptional = irisExerciseChatSessionRepository.findLatestByExerciseIdAndUserIdWithMessages(exercise.getId(), user.getId(), Pageable.ofSize(1)).stream() + .findFirst(); + + return sessionOptional.orElseGet(() -> createSessionInternal(exercise, user, sendInitialMessageIfCreated)); + } + + /** + * Creates a new Iris session for the given exercise and user. + * + * @param exercise The exercise the session belongs to + * @param user The user the session belongs to + * @param sendInitialMessage Whether to send an initial message from Iris + * @return The created session + */ + public IrisExerciseChatSession createSession(ProgrammingExercise exercise, User user, boolean sendInitialMessage) { + user.hasAcceptedIrisElseThrow(); + authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.STUDENT, exercise, user); + irisSettingsService.isEnabledForElseThrow(IrisSubSettingsType.CHAT, exercise); + return createSessionInternal(exercise, user, sendInitialMessage); + } + + /** + * Creates a new Iris session for the given exercise and user. + * + * @param exercise The exercise the session belongs to + * @param user The user the session belongs to + * @param sendInitialMessage Whether to send an initial message from Iris + * @return The created session + */ + private IrisExerciseChatSession createSessionInternal(ProgrammingExercise exercise, User user, boolean sendInitialMessage) { + checkIfExamExercise(exercise); + + var session = irisExerciseChatSessionRepository.save(new IrisExerciseChatSession(exercise, user)); + + if (sendInitialMessage) { + // Run async to allow the session to be returned immediately + CompletableFuture.runAsync(() -> requestAndHandleResponse(session)); + } + + return session; + } + @Override protected void setLLMTokenUsageParameters(LLMTokenUsageService.LLMTokenUsageBuilder builder, IrisExerciseChatSession session) { var exercise = session.getExercise(); builder.withCourse(exercise.getCourseViaExerciseGroupOrCourseMember().getId()).withExercise(exercise.getId()); } + + /** + * Validates the exercise and throws an exception if it is not a programming exercise or an exam exercise. + * + * @param exercise The exercise to check + * @throws IrisUnsupportedExerciseTypeException if the exercise is not a programming exercise or an exam exercise + */ + private ProgrammingExercise validateExercise(Exercise exercise) { + if (!(exercise instanceof ProgrammingExercise programmingExercise)) { + throw new IrisUnsupportedExerciseTypeException("Iris events are only supported for programming exercises"); + } + checkIfExamExercise(exercise); + + return programmingExercise; + } + + /** + * Checks if the exercise is an exam exercise and throws an exception if it is. + * + * @param exercise The exercise to check + */ + private void checkIfExamExercise(Exercise exercise) { + if (exercise.isExamExercise()) { + throw new IrisUnsupportedExerciseTypeException("Iris is not supported for exam exercises"); + } + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisTextExerciseChatSessionService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisTextExerciseChatSessionService.java index 8702db7bdf54..2c2875295cf8 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisTextExerciseChatSessionService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisTextExerciseChatSessionService.java @@ -1,6 +1,7 @@ package de.tum.cit.aet.artemis.iris.service.session; import java.util.Comparator; +import java.util.Optional; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; @@ -103,6 +104,7 @@ public void requestAndHandleResponse(IrisTextExerciseChatSession irisSession) { pyrisPipelineService.executePipeline( "text-exercise-chat", "default", + Optional.empty(), pyrisJobService.createTokenForJob(token -> new TextExerciseChatJob(token, course.getId(), exercise.getId(), session.getId())), dto -> new PyrisTextExerciseChatPipelineExecutionDTO(dto, PyrisTextExerciseDTO.of(exercise), conversation, latestSubmissionText), stages -> irisChatWebsocketService.sendMessage(session, null, stages) diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisUnsupportedExerciseTypeException.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisUnsupportedExerciseTypeException.java new file mode 100644 index 000000000000..1ee03d84ec4c --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisUnsupportedExerciseTypeException.java @@ -0,0 +1,11 @@ +package de.tum.cit.aet.artemis.iris.service.session; + +/** + * Exception thrown when an unsupported exercise type is encountered in Iris operations. + */ +public class IrisUnsupportedExerciseTypeException extends RuntimeException { + + public IrisUnsupportedExerciseTypeException(String message) { + super(message); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSettingsService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSettingsService.java index d286def04e19..8ed823adee2c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSettingsService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSettingsService.java @@ -41,6 +41,7 @@ import de.tum.cit.aet.artemis.iris.domain.settings.IrisSubSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisSubSettingsType; import de.tum.cit.aet.artemis.iris.domain.settings.IrisTextExerciseChatSubSettings; +import de.tum.cit.aet.artemis.iris.domain.settings.event.IrisEventType; import de.tum.cit.aet.artemis.iris.dto.IrisCombinedSettingsDTO; import de.tum.cit.aet.artemis.iris.repository.IrisSettingsRepository; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; @@ -84,11 +85,11 @@ public IrisSettingsService(IrisSettingsRepository irisSettingsRepository, IrisSu /** * Hooks into the {@link ApplicationReadyEvent} and creates or updates the global IrisSettings object on startup. * - * @param event Unused event param used to specify when the method should be executed + * @param ignoredEvent Unused event param used to specify when the method should be executed */ @Profile(PROFILE_SCHEDULING) @EventListener - public void execute(ApplicationReadyEvent event) throws Exception { + public void execute(ApplicationReadyEvent ignoredEvent) throws Exception { var allGlobalSettings = irisSettingsRepository.findAllGlobalSettings(); if (allGlobalSettings.isEmpty()) { createInitialGlobalSettings(); @@ -475,6 +476,38 @@ public void isEnabledForElseThrow(IrisSubSettingsType type, Course course) { } } + /** + * Checks whether an Iris event is enabled for a course. + * Throws an exception if the chat feature is disabled. + * Throws an exception if the event is disabled. + * + * @param type The Iris event to check + * @param course The course to check + */ + public void isActivatedForElseThrow(IrisEventType type, Course course) { + isEnabledForElseThrow(IrisSubSettingsType.CHAT, course); + + if (!isActivatedFor(type, course)) { + throw new AccessForbiddenAlertException("The Iris " + type.name() + " event is disabled for this course.", "Iris", "iris." + type.name().toLowerCase() + "Disabled"); + } + } + + /** + * Checks whether an Iris event is enabled for an exercise. + * Throws an exception if the chat feature is disabled. + * Throws an exception if the event is disabled. + * + * @param type The Iris event to check + * @param exercise The exercise to check + */ + public void isActivatedForElseThrow(IrisEventType type, Exercise exercise) { + isEnabledForElseThrow(IrisSubSettingsType.CHAT, exercise); + + if (!isActivatedFor(type, exercise)) { + throw new AccessForbiddenAlertException("The Iris " + type.name() + " event is disabled for this exercise.", "Iris", "iris." + type.name().toLowerCase() + "Disabled"); + } + } + /** * Checks whether an Iris feature is enabled for a course. * @@ -499,6 +532,30 @@ public boolean isEnabledFor(IrisSubSettingsType type, Exercise exercise) { return isFeatureEnabledInSettings(settings, type); } + /** + * Checks whether an Iris event is enabled for a course. + * + * @param type The Iris event to check + * @param course The course to check + * @return Whether the Iris event is active for the course + */ + public boolean isActivatedFor(IrisEventType type, Course course) { + var settings = getCombinedIrisSettingsFor(course, false); + return isEventEnabledInSettings(settings, type); + } + + /** + * Checks whether an Iris event is enabled for an exercise. + * + * @param type The Iris event to check + * @param exercise The exercise to check + * @return Whether the Iris event is active for the exercise + */ + public boolean isActivatedFor(IrisEventType type, Exercise exercise) { + var settings = getCombinedIrisSettingsFor(exercise, false); + return isEventEnabledInSettings(settings, type); + } + /** * Checks whether an Iris feature is enabled for an exercise. * Throws an exception if the feature is disabled. @@ -627,6 +684,7 @@ public IrisExerciseSettings getDefaultSettingsFor(Exercise exercise) { settings.setExercise(exercise); settings.setIrisChatSettings(new IrisChatSubSettings()); settings.setIrisTextExerciseChatSettings(new IrisTextExerciseChatSubSettings()); + return settings; } @@ -690,4 +748,33 @@ private boolean isFeatureEnabledInSettings(IrisCombinedSettingsDTO settings, Iri case LECTURE_INGESTION -> settings.irisLectureIngestionSettings().enabled(); }; } + + /** + * Checks if whether an Iris event is enabled in the given settings + * + * @param settings the settings + * @param type the type of the event + * @return Whether the settings type is enabled + */ + private boolean isEventEnabledInSettings(IrisCombinedSettingsDTO settings, IrisEventType type) { + return switch (type) { + case PROGRESS_STALLED -> { + if (settings.irisChatSettings().disabledProactiveEvents() != null) { + yield !settings.irisChatSettings().disabledProactiveEvents().contains(IrisEventType.PROGRESS_STALLED.name().toLowerCase()); + } + else { + yield true; + } + } + case BUILD_FAILED -> { + if (settings.irisChatSettings().disabledProactiveEvents() != null) { + yield !settings.irisChatSettings().disabledProactiveEvents().contains(IrisEventType.BUILD_FAILED.name().toLowerCase()); + } + else { + yield true; + } + } + default -> throw new IllegalStateException("Unexpected value: " + type); // TODO: Add JOL event, once Course Chat Settings are implemented + }; + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSubSettingsService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSubSettingsService.java index c6c17601e5af..6d0a03b002c2 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSubSettingsService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSubSettingsService.java @@ -53,6 +53,7 @@ public IrisSubSettingsService(AuthorizationCheckService authCheckService) { * - If the user is not an admin the rate limit will not be updated. * - If the user is not an admin the allowed models will not be updated. * - If the user is not an admin the preferred model will only be updated if it is included in the allowed models. + * - If the user is not an admin the disabled proactive events will only be updated if the settings are exercise or course settings. * * @param currentSettings Current chat sub settings. * @param newSettings Updated chat sub settings. @@ -82,6 +83,13 @@ public IrisChatSubSettings update(IrisChatSubSettings currentSettings, IrisChatS currentSettings.setRateLimit(newSettings.getRateLimit()); currentSettings.setRateLimitTimeframeHours(newSettings.getRateLimitTimeframeHours()); } + if (authCheckService.isAdmin() && settingsType == IrisSettingsType.GLOBAL) { + currentSettings.setDisabledProactiveEvents(newSettings.getDisabledProactiveEvents()); + + } + else if (settingsType == IrisSettingsType.COURSE || settingsType == IrisSettingsType.EXERCISE) { + currentSettings.setDisabledProactiveEvents(newSettings.getDisabledProactiveEvents()); + } currentSettings.setAllowedVariants(selectAllowedVariants(currentSettings.getAllowedVariants(), newSettings.getAllowedVariants())); currentSettings.setSelectedVariant(validateSelectedVariant(currentSettings.getSelectedVariant(), newSettings.getSelectedVariant(), currentSettings.getAllowedVariants(), parentSettings != null ? parentSettings.allowedVariants() : null)); @@ -272,7 +280,9 @@ public IrisCombinedChatSubSettingsDTO combineChatSettings(ArrayList settingsList, Function subSettingsFunction) { + private boolean getCombinedEnabled(List settingsList, Function subSettingsFunction) { for (var irisSettings : settingsList) { if (irisSettings == null) { return false; @@ -413,4 +423,21 @@ private SortedSet getCombinedEnabledForCategories(List set .map(IrisChatSubSettings::getEnabledForCategories).filter(Objects::nonNull).filter(models -> !models.isEmpty()).reduce((first, second) -> second) .orElse(new TreeSet<>()); } + + /** + * Combines the disabledProactiveEvents field of multiple {@link IrisSettings} objects. + * Simply takes the last disabledProactiveEvents. + * + * @param settingsList List of {@link IrisSettings} objects to combine. + * @param subSettingsFunction Function to get the sub settings from an IrisSettings object. + * @return Combined disabledProactiveEvents field. + */ + private SortedSet getCombinedDisabledForEvents(List settingsList, Function subSettingsFunction) { + return settingsList.stream().filter(Objects::nonNull).map(subSettingsFunction).filter(Objects::nonNull).map(IrisChatSubSettings::getDisabledProactiveEvents) + .filter(Objects::nonNull).reduce((first, second) -> { + var result = new TreeSet<>(second); + result.addAll(first); + return result; + }).orElse(new TreeSet<>()); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisExerciseChatSessionResource.java b/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisExerciseChatSessionResource.java index 3f2a0bdb6ab7..c7e55ff22d55 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisExerciseChatSessionResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisExerciseChatSessionResource.java @@ -7,7 +7,6 @@ import java.util.List; import org.springframework.context.annotation.Profile; -import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -26,6 +25,7 @@ import de.tum.cit.aet.artemis.iris.service.IrisRateLimitService; import de.tum.cit.aet.artemis.iris.service.IrisSessionService; import de.tum.cit.aet.artemis.iris.service.pyris.PyrisHealthIndicator; +import de.tum.cit.aet.artemis.iris.service.session.IrisExerciseChatSessionService; import de.tum.cit.aet.artemis.iris.service.settings.IrisSettingsService; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; @@ -53,9 +53,11 @@ public class IrisExerciseChatSessionResource { private final IrisExerciseChatSessionRepository irisExerciseChatSessionRepository; + private final IrisExerciseChatSessionService irisExerciseChatSessionService; + protected IrisExerciseChatSessionResource(IrisExerciseChatSessionRepository irisExerciseChatSessionRepository, UserRepository userRepository, ExerciseRepository exerciseRepository, IrisSessionService irisSessionService, IrisSettingsService irisSettingsService, PyrisHealthIndicator pyrisHealthIndicator, - IrisRateLimitService irisRateLimitService) { + IrisRateLimitService irisRateLimitService, IrisExerciseChatSessionService irisExerciseChatSessionService) { this.irisExerciseChatSessionRepository = irisExerciseChatSessionRepository; this.userRepository = userRepository; this.irisSessionService = irisSessionService; @@ -63,6 +65,7 @@ protected IrisExerciseChatSessionResource(IrisExerciseChatSessionRepository iris this.pyrisHealthIndicator = pyrisHealthIndicator; this.irisRateLimitService = irisRateLimitService; this.exerciseRepository = exerciseRepository; + this.irisExerciseChatSessionService = irisExerciseChatSessionService; } /** @@ -75,20 +78,13 @@ protected IrisExerciseChatSessionResource(IrisExerciseChatSessionRepository iris @EnforceAtLeastStudentInExercise public ResponseEntity getCurrentSessionOrCreateIfNotExists(@PathVariable Long exerciseId) throws URISyntaxException { var exercise = exerciseRepository.findByIdElseThrow(exerciseId); - validateExercise(exercise); + ProgrammingExercise programmingExercise = validateExercise(exercise); irisSettingsService.isEnabledForElseThrow(IrisSubSettingsType.CHAT, exercise); var user = userRepository.getUserWithGroupsAndAuthorities(); - var sessionOptional = irisExerciseChatSessionRepository.findLatestByExerciseIdAndUserIdWithMessages(exercise.getId(), user.getId(), Pageable.ofSize(1)).stream() - .findFirst(); - if (sessionOptional.isPresent()) { - var session = sessionOptional.get(); - irisSessionService.checkHasAccessToIrisSession(session, user); - return ResponseEntity.ok(session); - } - - return createSessionForExercise(exerciseId); + var session = irisExerciseChatSessionService.getCurrentSessionOrCreateIfNotExists(programmingExercise, user, false); + return ResponseEntity.ok(session); } /** @@ -101,9 +97,9 @@ public ResponseEntity getCurrentSessionOrCreateIfNotExi @EnforceAtLeastStudentInExercise public ResponseEntity> getAllSessions(@PathVariable Long exerciseId) { var exercise = exerciseRepository.findByIdElseThrow(exerciseId); - validateExercise(exercise); + ProgrammingExercise programmingExercise = validateExercise(exercise); - irisSettingsService.isEnabledForElseThrow(IrisSubSettingsType.CHAT, exercise); + irisSettingsService.isEnabledForElseThrow(IrisSubSettingsType.CHAT, programmingExercise); var user = userRepository.getUserWithGroupsAndAuthorities(); var sessions = irisExerciseChatSessionRepository.findByExerciseIdAndUserIdElseThrow(exercise.getId(), user.getId()); @@ -125,10 +121,10 @@ public ResponseEntity createSessionForExercise(@PathVar var exercise = exerciseRepository.findByIdElseThrow(exerciseId); ProgrammingExercise programmingExercise = validateExercise(exercise); - irisSettingsService.isEnabledForElseThrow(IrisSubSettingsType.CHAT, exercise); + irisSettingsService.isEnabledForElseThrow(IrisSubSettingsType.CHAT, programmingExercise); var user = userRepository.getUserWithGroupsAndAuthorities(); - var session = irisExerciseChatSessionRepository.save(new IrisExerciseChatSession(programmingExercise, user)); + var session = irisExerciseChatSessionService.createSession(programmingExercise, user, false); var uriString = "/api/iris/sessions/" + session.getId(); return ResponseEntity.created(new URI(uriString)).body(session); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseRepository.java index 4f9e61fda04d..73ea3f0cddc3 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseRepository.java @@ -250,6 +250,9 @@ default ProgrammingExercise findOneByProjectKeyOrThrow(String projectKey, boolea """) Optional findWithEagerStudentParticipationsStudentAndLegalSubmissionsById(@Param("exerciseId") long exerciseId); + @EntityGraph(type = LOAD, attributePaths = { "templateParticipation", "solutionParticipation", "studentParticipations.team.students", "buildConfig" }) + Optional findWithAllParticipationsAndBuildConfigById(long exerciseId); + @Query(""" SELECT pe FROM ProgrammingExercise pe diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingMessagingService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingMessagingService.java index dfb4e931ac27..8c73f1b25850 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingMessagingService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingMessagingService.java @@ -19,11 +19,14 @@ import de.tum.cit.aet.artemis.assessment.web.ResultWebsocketService; import de.tum.cit.aet.artemis.communication.service.WebsocketMessagingService; import de.tum.cit.aet.artemis.communication.service.notifications.GroupNotificationService; +import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.exercise.domain.Team; import de.tum.cit.aet.artemis.exercise.domain.participation.Participation; import de.tum.cit.aet.artemis.exercise.domain.participation.StudentParticipation; import de.tum.cit.aet.artemis.exercise.dto.SubmissionDTO; import de.tum.cit.aet.artemis.exercise.repository.TeamRepository; +import de.tum.cit.aet.artemis.iris.service.pyris.PyrisEventService; +import de.tum.cit.aet.artemis.iris.service.pyris.event.NewResultEvent; import de.tum.cit.aet.artemis.lti.service.LtiNewResultService; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseParticipation; @@ -48,13 +51,29 @@ public class ProgrammingMessagingService { private final TeamRepository teamRepository; + private final Optional pyrisEventService; + public ProgrammingMessagingService(GroupNotificationService groupNotificationService, WebsocketMessagingService websocketMessagingService, - ResultWebsocketService resultWebsocketService, Optional ltiNewResultService, TeamRepository teamRepository) { + ResultWebsocketService resultWebsocketService, Optional ltiNewResultService, TeamRepository teamRepository, + Optional pyrisEventService) { this.groupNotificationService = groupNotificationService; this.websocketMessagingService = websocketMessagingService; this.resultWebsocketService = resultWebsocketService; this.ltiNewResultService = ltiNewResultService; this.teamRepository = teamRepository; + this.pyrisEventService = pyrisEventService; + } + + private static String getExerciseTopicForTAAndAbove(long exerciseId) { + return EXERCISE_TOPIC_ROOT + exerciseId + PROGRAMMING_SUBMISSION_TOPIC; + } + + public static String getProgrammingExerciseTestCaseChangedTopic(Long programmingExerciseId) { + return "/topic/programming-exercises/" + programmingExerciseId + "/test-cases-changed"; + } + + private static String getProgrammingExerciseAllExerciseBuildsTriggeredTopic(Long programmingExerciseId) { + return "/topic/programming-exercises/" + programmingExerciseId + "/all-builds-triggered"; } public void notifyInstructorAboutStartedExerciseBuildRun(ProgrammingExercise programmingExercise) { @@ -144,18 +163,6 @@ public void notifyInstructorGroupAboutIllegalSubmissionsForExercise(ProgrammingE groupNotificationService.notifyInstructorGroupAboutIllegalSubmissionsForExercise(exercise, notificationText); } - private static String getExerciseTopicForTAAndAbove(long exerciseId) { - return EXERCISE_TOPIC_ROOT + exerciseId + PROGRAMMING_SUBMISSION_TOPIC; - } - - public static String getProgrammingExerciseTestCaseChangedTopic(Long programmingExerciseId) { - return "/topic/programming-exercises/" + programmingExerciseId + "/test-cases-changed"; - } - - private static String getProgrammingExerciseAllExerciseBuildsTriggeredTopic(Long programmingExerciseId) { - return "/topic/programming-exercises/" + programmingExerciseId + "/all-builds-triggered"; - } - /** * Notify user about new result. * @@ -167,9 +174,34 @@ public void notifyUserAboutNewResult(Result result, ProgrammingExerciseParticipa // notify user via websocket resultWebsocketService.broadcastNewResult((Participation) participation, result); - if (participation instanceof ProgrammingExerciseStudentParticipation studentParticipation && ltiNewResultService.isPresent()) { + if (participation instanceof ProgrammingExerciseStudentParticipation studentParticipation) { // do not try to report results for template or solution participations - ltiNewResultService.get().onNewResult(studentParticipation); + ltiNewResultService.ifPresent(newResultService -> newResultService.onNewResult(studentParticipation)); + // Inform Iris about the submission status + notifyIrisAboutSubmissionStatus(result, studentParticipation); + } + } + + /** + * Notify Iris about the submission status for the given result and student participation. + * If the submission was successful, Iris will be informed about the successful submission. + * If the submission failed, Iris will be informed about the submission failure. + * Iris will only be informed about the submission status if the participant is a user. + * + * @param result the result for which Iris should be informed about the submission status + * @param studentParticipation the student participation for which Iris should be informed about the submission status + */ + private void notifyIrisAboutSubmissionStatus(Result result, ProgrammingExerciseStudentParticipation studentParticipation) { + if (studentParticipation.getParticipant() instanceof User) { + pyrisEventService.ifPresent(eventService -> { + // Inform event service about the new result + try { + eventService.trigger(new NewResultEvent(result)); + } + catch (Exception e) { + log.error("Could not trigger service for result {}", result.getId(), e); + } + }); } } } diff --git a/src/main/resources/config/liquibase/changelog/20241120213100_changelog.xml b/src/main/resources/config/liquibase/changelog/20241120213100_changelog.xml new file mode 100644 index 000000000000..2092da1126ab --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20241120213100_changelog.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml index a2f522a1674c..9dd06528e6f3 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -37,6 +37,7 @@ + diff --git a/src/main/webapp/app/entities/iris/settings/iris-sub-settings.model.ts b/src/main/webapp/app/entities/iris/settings/iris-sub-settings.model.ts index 6b99edff0804..1fc44fbf261d 100644 --- a/src/main/webapp/app/entities/iris/settings/iris-sub-settings.model.ts +++ b/src/main/webapp/app/entities/iris/settings/iris-sub-settings.model.ts @@ -8,6 +8,11 @@ export enum IrisSubSettingsType { COMPETENCY_GENERATION = 'competency-generation', } +export enum IrisEventType { + BUILD_FAILED = 'build_failed', + PROGRESS_STALLED = 'progress_stalled', +} + export abstract class IrisSubSettings implements BaseEntity { id?: number; type: IrisSubSettingsType; @@ -15,6 +20,7 @@ export abstract class IrisSubSettings implements BaseEntity { allowedVariants?: string[]; selectedVariant?: string; enabledForCategories?: string[]; + disabledProactiveEvents?: IrisEventType[]; } export class IrisChatSubSettings extends IrisSubSettings { diff --git a/src/main/webapp/app/iris/exercise-chatbot/exercise-chatbot-button.component.html b/src/main/webapp/app/iris/exercise-chatbot/exercise-chatbot-button.component.html index 71ef760d0169..8dbc89e9a541 100644 --- a/src/main/webapp/app/iris/exercise-chatbot/exercise-chatbot-button.component.html +++ b/src/main/webapp/app/iris/exercise-chatbot/exercise-chatbot-button.component.html @@ -1,4 +1,21 @@ @if (!chatOpen) { +
+ @if (newIrisMessage) { + + @if (this.isOverflowing) { +
+ + +
+ } + } +
@if (hasNewMessages) { diff --git a/src/main/webapp/app/iris/exercise-chatbot/exercise-chatbot-button.component.scss b/src/main/webapp/app/iris/exercise-chatbot/exercise-chatbot-button.component.scss index 1073fdfd8cc0..7502bcce9211 100644 --- a/src/main/webapp/app/iris/exercise-chatbot/exercise-chatbot-button.component.scss +++ b/src/main/webapp/app/iris/exercise-chatbot/exercise-chatbot-button.component.scss @@ -11,6 +11,71 @@ } } +.message-bubble { + --r: 13px; /* the radius */ + + position: absolute; + z-index: 50; + bottom: 100px; + right: 80px; + width: 350px; + height: 150px; + background-color: var(--iris-client-chat-background); + cursor: pointer; + transition: all 0.1s ease-in-out; + overflow-y: hidden; + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; + padding: 10px; + display: flex; + border-radius: var(--r); + border-bottom-right-radius: 0; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); + + &:hover { + transform: scale(1.05); + + .read-more > span { + text-decoration: underline; + } + } +} + +.content-overflow { + &::before { + content: ''; + bottom: 0; + left: 0; + width: 100%; + height: 100%; + position: absolute; + background: linear-gradient(0deg, var(--iris-chat-bubble-fade) 2%, transparent); + } +} + +.read-more { + position: absolute; + bottom: 5px; + left: 50%; + transform: translateX(-50%); + padding: 5px 10px; + width: 150px; + font-size: 14px; + z-index: 100; + color: var(--link-color); + transition: all 0.2s ease; + + &:focus-visible { + outline: 2px solid var(--link-color); + outline-offset: 2px; + } +} + +.hidden { + display: none; +} + .btn-circle { width: 40px; height: 40px; diff --git a/src/main/webapp/app/iris/exercise-chatbot/exercise-chatbot-button.component.ts b/src/main/webapp/app/iris/exercise-chatbot/exercise-chatbot-button.component.ts index 0e0cb8b6a4ce..b36b9edea7cb 100644 --- a/src/main/webapp/app/iris/exercise-chatbot/exercise-chatbot-button.component.ts +++ b/src/main/webapp/app/iris/exercise-chatbot/exercise-chatbot-button.component.ts @@ -1,17 +1,41 @@ -import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { MatDialog, MatDialogRef } from '@angular/material/dialog'; import { Overlay } from '@angular/cdk/overlay'; import { ActivatedRoute } from '@angular/router'; import { IrisChatbotWidgetComponent } from 'app/iris/exercise-chatbot/widget/chatbot-widget.component'; -import { Subscription } from 'rxjs'; -import { faChevronDown, faCircle } from '@fortawesome/free-solid-svg-icons'; +import { EMPTY, Subscription, filter, of, switchMap } from 'rxjs'; +import { faAngleDoubleDown, faChevronDown, faCircle } from '@fortawesome/free-solid-svg-icons'; import { IrisLogoLookDirection, IrisLogoSize } from 'app/iris/iris-logo/iris-logo.component'; import { ChatServiceMode, IrisChatService } from 'app/iris/iris-chat.service'; +import { animate, state, style, transition, trigger } from '@angular/animations'; +import { IrisTextMessageContent } from 'app/entities/iris/iris-content-type.model'; @Component({ selector: 'jhi-exercise-chatbot-button', templateUrl: './exercise-chatbot-button.component.html', styleUrls: ['./exercise-chatbot-button.component.scss'], + animations: [ + trigger('expandAnimation', [ + state( + 'hidden', + style({ + opacity: 0, + transform: 'scale(0)', + transformOrigin: 'bottom right', + }), + ), + state( + 'visible', + style({ + opacity: 1, + transform: 'scale(1)', + transformOrigin: 'bottom right', + }), + ), + transition('hidden => visible', animate('300ms ease-out')), + transition('visible => hidden', animate('300ms ease-in')), + ]), + ], }) export class IrisExerciseChatbotButtonComponent implements OnInit, OnDestroy { @Input() @@ -19,13 +43,22 @@ export class IrisExerciseChatbotButtonComponent implements OnInit, OnDestroy { dialogRef: MatDialogRef | null = null; chatOpen = false; + isOverflowing = false; hasNewMessages = false; + newIrisMessage: string | undefined; + + private readonly CHAT_BUBBLE_TIMEOUT = 10000; + private numNewMessagesSubscription: Subscription; private paramsSubscription: Subscription; + private latestIrisMessageSubscription: Subscription; // Icons faCircle = faCircle; faChevronDown = faChevronDown; + faAngleDoubleDown = faAngleDoubleDown; + + @ViewChild('chatBubble') chatBubble: ElementRef; protected readonly IrisLogoLookDirection = IrisLogoLookDirection; protected readonly IrisLogoSize = IrisLogoSize; @@ -48,6 +81,24 @@ export class IrisExerciseChatbotButtonComponent implements OnInit, OnDestroy { this.numNewMessagesSubscription = this.chatService.numNewMessages.subscribe((num) => { this.hasNewMessages = num > 0; }); + this.latestIrisMessageSubscription = this.chatService.newIrisMessage + .pipe( + filter((msg) => !!msg), + switchMap((msg) => { + if (msg!.content && msg!.content.length > 0) { + return of((msg!.content[0] as IrisTextMessageContent).textContent); + } + return EMPTY; + }), + ) + .subscribe((message) => { + this.newIrisMessage = message; + setTimeout(() => this.checkOverflow(), 0); + setTimeout(() => { + this.newIrisMessage = undefined; + this.isOverflowing = false; + }, this.CHAT_BUBBLE_TIMEOUT); + }); } ngOnDestroy() { @@ -57,6 +108,9 @@ export class IrisExerciseChatbotButtonComponent implements OnInit, OnDestroy { } this.numNewMessagesSubscription?.unsubscribe(); this.paramsSubscription.unsubscribe(); + this.latestIrisMessageSubscription.unsubscribe(); + this.newIrisMessage = undefined; + this.isOverflowing = false; } /** @@ -74,18 +128,35 @@ export class IrisExerciseChatbotButtonComponent implements OnInit, OnDestroy { } } + /** + * Checks if the chat bubble is overflowing and sets isOverflowing to true if it is. + */ + public checkOverflow() { + const element = this.chatBubble?.nativeElement; + this.isOverflowing = !!element && element.scrollHeight > element.clientHeight; + } + /** * Opens the chat dialog using MatDialog. * Sets the configuration options for the dialog, including position, size, and data. */ - openChat() { + public openChat() { this.chatOpen = true; + this.newIrisMessage = undefined; + this.isOverflowing = false; this.dialogRef = this.dialog.open(IrisChatbotWidgetComponent, { hasBackdrop: false, scrollStrategy: this.overlay.scrollStrategies.noop(), position: { bottom: '0px', right: '0px' }, disableClose: true, }); - this.dialogRef.afterClosed().subscribe(() => (this.chatOpen = false)); + this.dialogRef.afterClosed().subscribe(() => this.handleDialogClose()); } + + private handleDialogClose() { + this.chatOpen = false; + this.newIrisMessage = undefined; + } + + protected readonly IrisTextMessageContent = IrisTextMessageContent; } diff --git a/src/main/webapp/app/iris/iris-chat.service.ts b/src/main/webapp/app/iris/iris-chat.service.ts index 515faab20a00..c8e243f0f6c3 100644 --- a/src/main/webapp/app/iris/iris-chat.service.ts +++ b/src/main/webapp/app/iris/iris-chat.service.ts @@ -28,6 +28,7 @@ export enum ChatServiceMode { export class IrisChatService implements OnDestroy { sessionId?: number; messages: BehaviorSubject = new BehaviorSubject([]); + newIrisMessage: BehaviorSubject = new BehaviorSubject(undefined); numNewMessages: BehaviorSubject = new BehaviorSubject(0); stages: BehaviorSubject = new BehaviorSubject([]); suggestions: BehaviorSubject = new BehaviorSubject([]); @@ -98,6 +99,9 @@ export class IrisChatService implements OnDestroy { private replaceOrAddMessage(message: IrisMessage) { const messageWasReplaced = this.replaceMessage(message); if (!messageWasReplaced) { + if (message.sender === IrisSender.LLM) { + this.newIrisMessage.next(message); + } this.messages.next([...this.messages.getValue(), message]); } } @@ -152,6 +156,7 @@ export class IrisChatService implements OnDestroy { public messagesRead(): void { this.numNewMessages.next(0); + this.newIrisMessage.next(undefined); } public setUserAccepted(): void { @@ -239,6 +244,7 @@ export class IrisChatService implements OnDestroy { this.stages.next([]); this.suggestions.next([]); this.numNewMessages.next(0); + this.newIrisMessage.next(undefined); } this.error.next(undefined); } diff --git a/src/main/webapp/app/iris/settings/iris-settings-update/iris-common-sub-settings-update/iris-common-sub-settings-update.component.html b/src/main/webapp/app/iris/settings/iris-settings-update/iris-common-sub-settings-update/iris-common-sub-settings-update.component.html index 317a0c4cb265..69c3c88da691 100644 --- a/src/main/webapp/app/iris/settings/iris-settings-update/iris-common-sub-settings-update/iris-common-sub-settings-update.component.html +++ b/src/main/webapp/app/iris/settings/iris-settings-update/iris-common-sub-settings-update/iris-common-sub-settings-update.component.html @@ -43,6 +43,36 @@

+ + +

+
+
+ @for (event of exerciseChatEvents; track event) { +
+ + + @if (eventInParentDisabledStatusMap.get(event)) { + + } +
+ } +
+
+}

: @if (parentSubSettings) { diff --git a/src/main/webapp/app/iris/settings/iris-settings-update/iris-common-sub-settings-update/iris-common-sub-settings-update.component.ts b/src/main/webapp/app/iris/settings/iris-settings-update/iris-common-sub-settings-update/iris-common-sub-settings-update.component.ts index 78e23b9dbaa6..08661117e284 100644 --- a/src/main/webapp/app/iris/settings/iris-settings-update/iris-common-sub-settings-update/iris-common-sub-settings-update.component.ts +++ b/src/main/webapp/app/iris/settings/iris-settings-update/iris-common-sub-settings-update/iris-common-sub-settings-update.component.ts @@ -1,9 +1,9 @@ import { Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; -import { IrisSubSettings, IrisSubSettingsType } from 'app/entities/iris/settings/iris-sub-settings.model'; +import { IrisEventType, IrisSubSettings, IrisSubSettingsType } from 'app/entities/iris/settings/iris-sub-settings.model'; import { IrisVariant } from 'app/entities/iris/settings/iris-variant'; import { AccountService } from 'app/core/auth/account.service'; import { ButtonType } from 'app/shared/components/button.component'; -import { faTrash } from '@fortawesome/free-solid-svg-icons'; +import { faCircleExclamation, faQuestionCircle, faTrash } from '@fortawesome/free-solid-svg-icons'; import { IrisSettingsType } from 'app/entities/iris/settings/iris-settings.model'; import { IrisSettingsService } from 'app/iris/settings/shared/iris-settings.service'; import { CourseManagementService } from 'app/course/manage/course-management.service'; @@ -36,6 +36,8 @@ export class IrisCommonSubSettingsUpdateComponent implements OnInit, OnChanges { inheritAllowedVariants: boolean; + eventInParentDisabledStatusMap = new Map(); + availableVariants: IrisVariant[] = []; allowedVariants: IrisVariant[] = []; @@ -44,6 +46,8 @@ export class IrisCommonSubSettingsUpdateComponent implements OnInit, OnChanges { categories: string[] = []; + exerciseChatEvents: IrisEventType[] = [IrisEventType.BUILD_FAILED, IrisEventType.PROGRESS_STALLED]; + // Settings types EXERCISE = IrisSettingsType.EXERCISE; COURSE = IrisSettingsType.COURSE; @@ -52,11 +56,18 @@ export class IrisCommonSubSettingsUpdateComponent implements OnInit, OnChanges { // Button types WARNING = ButtonType.WARNING; // Icons - faTrash = faTrash; + readonly faTrash = faTrash; + readonly faQuestionCircle = faQuestionCircle; + readonly faCircleExclamation = faCircleExclamation; protected readonly IrisSubSettings = IrisSubSettings; protected readonly IrisSubSettingsType = IrisSubSettingsType; + protected readonly eventTranslationKeys = { + [IrisEventType.BUILD_FAILED]: 'artemisApp.iris.settings.subSettings.proactivityBuildFailedEventEnabled.label', + [IrisEventType.PROGRESS_STALLED]: 'artemisApp.iris.settings.subSettings.proactivityProgressStalledEventEnabled.label', + }; + constructor( accountService: AccountService, private irisSettingsService: IrisSettingsService, @@ -81,6 +92,9 @@ export class IrisCommonSubSettingsUpdateComponent implements OnInit, OnChanges { if (changes.subSettings) { this.enabled = this.subSettings?.enabled ?? false; } + if (changes.parentSubSettings || changes.subSettings) { + this.updateEventDisabledStatus(); + } } loadCategories() { @@ -173,6 +187,20 @@ export class IrisCommonSubSettingsUpdateComponent implements OnInit, OnChanges { } } + onEventToggleChange(event: IrisEventType) { + if (!this.subSettings) { + return; + } + if (!this.subSettings.disabledProactiveEvents) { + this.subSettings.disabledProactiveEvents = []; + } + if (this.subSettings.disabledProactiveEvents?.includes(event)) { + this.subSettings.disabledProactiveEvents = this.subSettings.disabledProactiveEvents!.filter((c) => c !== event); + } else { + this.subSettings.disabledProactiveEvents = [...(this.subSettings.disabledProactiveEvents ?? []), event] as IrisEventType[]; + } + } + get inheritDisabled() { if (this.parentSubSettings) { return !this.parentSubSettings.enabled; @@ -183,4 +211,19 @@ export class IrisCommonSubSettingsUpdateComponent implements OnInit, OnChanges { get isSettingsSwitchDisabled() { return this.inheritDisabled || (!this.isAdmin && this.settingsType !== this.EXERCISE); } + + /** + * Updates the event disabled status map based on the parent settings + * @private + */ + private updateEventDisabledStatus(): void { + this.exerciseChatEvents.forEach((event) => { + const isDisabled = + !this.subSettings?.enabled || + (this.parentSubSettings && + !this.subSettings?.disabledProactiveEvents?.includes(event) && + (this.parentSubSettings.disabledProactiveEvents?.includes(event) || !this.parentSubSettings.enabled)); + this.eventInParentDisabledStatusMap.set(event, isDisabled); + }); + } } diff --git a/src/main/webapp/content/scss/themes/_dark-variables.scss b/src/main/webapp/content/scss/themes/_dark-variables.scss index 836772022df2..19de3c6980ef 100644 --- a/src/main/webapp/content/scss/themes/_dark-variables.scss +++ b/src/main/webapp/content/scss/themes/_dark-variables.scss @@ -653,6 +653,7 @@ $competency-rings-blue-bg: transparentize($competency-rings-blue, 0.8); // Iris Chatbot $iris-chat-widget-background: var(--neutral-dark-l-5); +$iris-chat-bubble-fade: $black; $iris-client-chat-background: var(--neutral-dark-l-10); $iris-client-chat-input: var(--neutral-dark-l-5); $iris-my-chat-background: #008462; diff --git a/src/main/webapp/content/scss/themes/_default-variables.scss b/src/main/webapp/content/scss/themes/_default-variables.scss index 35047ea29f83..f8bbfad5151a 100644 --- a/src/main/webapp/content/scss/themes/_default-variables.scss +++ b/src/main/webapp/content/scss/themes/_default-variables.scss @@ -581,6 +581,7 @@ $competency-rings-blue-bg: transparentize($competency-rings-blue, 0.8); // Iris Chatbot // TODO: Use standard colors $iris-chat-widget-background: $white; +$iris-chat-bubble-fade: $white; $iris-client-chat-background: var(--gray-200); $iris-client-chat-input: $white; $iris-my-chat-background: #a9dcb5; diff --git a/src/main/webapp/i18n/de/iris.json b/src/main/webapp/i18n/de/iris.json index 6d1200737895..95fa36c63909 100644 --- a/src/main/webapp/i18n/de/iris.json +++ b/src/main/webapp/i18n/de/iris.json @@ -33,6 +33,19 @@ }, "hestiaSettings": "Hestia Einstellungen", "competencyGenerationSettings": "Kompetenzgenerierung Einstellungen", + "proactivityBuildFailedEventEnabled": { + "label": "Build-Fehler überwachen", + "tooltip": "Wenn aktiviert, wird Iris proaktiv Hilfe-Nachrichten senden, wenn der Build einer Abbgabe fehlschlägt." + }, + "proactivityProgressStalledEventEnabled": { + "label": "Übungsleistung überwachen", + "tooltip": "Wenn aktiviert, wird Iris proaktiv Hilfe-Nachrichten senden, wenn sich die Übungsleistung des Studenten nicht verbessert." + }, + "proactivitySettings": { + "title": "Proaktivitätseinstellungen", + "tooltip": "Aktiviere die untenstehenden Optionen, damit Iris proaktiv Studierende kontaktiert, wenn bestimmte Bedingungen erfüllt sind.", + "parentDisabled": "Iris sendet keine proaktiven Nachrichten für diese Option, da entweder die Option selbst, oder Iris in den aktuellen oder übergeordneten Einstellungen deaktiviert ist." + }, "enabled-disabled": "Aktiviert/Deaktiviert", "enabledForCategories": "Automatisch aktivieren für Kategorien", "variants": { @@ -83,6 +96,9 @@ "chat": { "helpOffer": "Wie kann ich Dir helfen?" }, + "chatBubble": { + "seeFull": "Vollständige Nachricht sehen" + }, "ingestionStates": { "loading": "Wird geladen...", "notStarted": "Nicht gestartet", diff --git a/src/main/webapp/i18n/en/iris.json b/src/main/webapp/i18n/en/iris.json index f0e4072441be..c36553db40fa 100644 --- a/src/main/webapp/i18n/en/iris.json +++ b/src/main/webapp/i18n/en/iris.json @@ -33,6 +33,19 @@ }, "hestiaSettings": "Hestia Settings", "competencyGenerationSettings": "Competency Generation Settings", + "proactivityBuildFailedEventEnabled": { + "label": "Monitor submission build failures", + "tooltip": "When enabled, Iris will proactively send help messages if a submission build fails." + }, + "proactivityProgressStalledEventEnabled": { + "label": "Monitor exercise performance progress", + "tooltip": "When enabled, Iris will proactively send help messages if the student's exercise performance does not improve." + }, + "proactivitySettings": { + "title": "Proactivity Settings", + "tooltip": "Enable options below to allow Iris to proactively reach out to students when specific conditions are met.", + "parentDisabled": "Iris won't send proactive messages for this option because either the option itself or Iris are disabled in the current or parent settings." + }, "enabled-disabled": "Enabled/Disabled", "enabledForCategories": "Automatically enable for categories", "variants": { @@ -83,6 +96,9 @@ "chat": { "helpOffer": "How can I help you today?" }, + "chatBubble": { + "seeFull": "See full message" + }, "ingestionStates": { "loading": "Loading", "notStarted": "Not started", diff --git a/src/test/java/de/tum/cit/aet/artemis/communication/PostingServiceUnitTest.java b/src/test/java/de/tum/cit/aet/artemis/communication/PostingServiceUnitTest.java index ced0b420a7a7..af03891f4cac 100644 --- a/src/test/java/de/tum/cit/aet/artemis/communication/PostingServiceUnitTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/communication/PostingServiceUnitTest.java @@ -220,42 +220,26 @@ void testParseUserMentionsMissingClosingTag() throws InvocationTargetException, @Test void shouldSetCorrectFlagsWhenPostsAreSaved() { - when(savedPostRepository.findSavedPostIdsByUserIdAndPostType( - testUser.getId(), - PostingType.POST - )).thenReturn(List.of(savedPost.getPostId())); + when(savedPostRepository.findSavedPostIdsByUserIdAndPostType(testUser.getId(), PostingType.POST)).thenReturn(List.of(savedPost.getPostId())); - when(savedPostRepository.findSavedPostIdsByUserIdAndPostType( - testUser.getId(), - PostingType.ANSWER - )).thenReturn(List.of(savedAnswer.getPostId(), savedAnswer2.getPostId())); + when(savedPostRepository.findSavedPostIdsByUserIdAndPostType(testUser.getId(), PostingType.ANSWER)).thenReturn(List.of(savedAnswer.getPostId(), savedAnswer2.getPostId())); postingService.preparePostForBroadcast(testPost); assertThat(testPost.getIsSaved()).isTrue(); - testPost.getAnswers().forEach(answer -> - assertThat(answer.getIsSaved()).isTrue() - ); + testPost.getAnswers().forEach(answer -> assertThat(answer.getIsSaved()).isTrue()); } @Test void shouldSetCorrectFlagsWhenPostsAreNotSaved() { - when(savedPostRepository.findSavedPostIdsByUserIdAndPostType( - testUser.getId(), - PostingType.POST - )).thenReturn(List.of()); + when(savedPostRepository.findSavedPostIdsByUserIdAndPostType(testUser.getId(), PostingType.POST)).thenReturn(List.of()); - when(savedPostRepository.findSavedPostIdsByUserIdAndPostType( - testUser.getId(), - PostingType.ANSWER - )).thenReturn(List.of()); + when(savedPostRepository.findSavedPostIdsByUserIdAndPostType(testUser.getId(), PostingType.ANSWER)).thenReturn(List.of()); postingService.preparePostForBroadcast(testPost); assertThat(testPost.getIsSaved()).isFalse(); - testPost.getAnswers().forEach(answer -> - assertThat(answer.getIsSaved()).isFalse() - ); + testPost.getAnswers().forEach(answer -> assertThat(answer.getIsSaved()).isFalse()); } /** diff --git a/src/test/java/de/tum/cit/aet/artemis/communication/service/SavedPostServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/communication/service/SavedPostServiceTest.java index 19db6d11d46b..aa8333b4de69 100644 --- a/src/test/java/de/tum/cit/aet/artemis/communication/service/SavedPostServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/communication/service/SavedPostServiceTest.java @@ -57,11 +57,7 @@ void setUp() { @Test void shouldSavePostSuccessfullyWhenPostIsNotSavedYet() { - when(savedPostRepository.findSavedPostByUserIdAndPostIdAndPostType( - testUser.getId(), - testPost.getId(), - PostingType.POST - )).thenReturn(null); + when(savedPostRepository.findSavedPostByUserIdAndPostIdAndPostType(testUser.getId(), testPost.getId(), PostingType.POST)).thenReturn(null); savedPostService.savePostForCurrentUser(testPost); @@ -70,11 +66,7 @@ void shouldSavePostSuccessfullyWhenPostIsNotSavedYet() { @Test void shouldNotSavePostWhenPostIsSavedAlready() { - when(savedPostRepository.findSavedPostByUserIdAndPostIdAndPostType( - testUser.getId(), - testPost.getId(), - PostingType.POST - )).thenReturn(testSavedPost); + when(savedPostRepository.findSavedPostByUserIdAndPostIdAndPostType(testUser.getId(), testPost.getId(), PostingType.POST)).thenReturn(testSavedPost); savedPostService.savePostForCurrentUser(testPost); @@ -83,11 +75,7 @@ void shouldNotSavePostWhenPostIsSavedAlready() { @Test void shouldRemoveSavedPostWhenPostIsBookmarked() { - when(savedPostRepository.findSavedPostByUserIdAndPostIdAndPostType( - testUser.getId(), - testPost.getId(), - PostingType.POST - )).thenReturn(testSavedPost); + when(savedPostRepository.findSavedPostByUserIdAndPostIdAndPostType(testUser.getId(), testPost.getId(), PostingType.POST)).thenReturn(testSavedPost); savedPostService.removeSavedPostForCurrentUser(testPost); @@ -96,11 +84,7 @@ void shouldRemoveSavedPostWhenPostIsBookmarked() { @Test void shouldNotRemoveSavedPostWhenPostIsNotBookmarked() { - when(savedPostRepository.findSavedPostByUserIdAndPostIdAndPostType( - testUser.getId(), - testPost.getId(), - PostingType.POST - )).thenReturn(null); + when(savedPostRepository.findSavedPostByUserIdAndPostIdAndPostType(testUser.getId(), testPost.getId(), PostingType.POST)).thenReturn(null); savedPostService.removeSavedPostForCurrentUser(testPost); @@ -109,11 +93,7 @@ void shouldNotRemoveSavedPostWhenPostIsNotBookmarked() { @Test void shouldUpdateStatusAndCompletedAtOfSavedPostWhenPostIsInProgress() { - when(savedPostRepository.findSavedPostByUserIdAndPostIdAndPostType( - testUser.getId(), - testPost.getId(), - PostingType.POST - )).thenReturn(testSavedPost); + when(savedPostRepository.findSavedPostByUserIdAndPostIdAndPostType(testUser.getId(), testPost.getId(), PostingType.POST)).thenReturn(testSavedPost); savedPostService.updateStatusOfSavedPostForCurrentUser(testPost, SavedPostStatus.COMPLETED); diff --git a/src/test/java/de/tum/cit/aet/artemis/core/connector/IrisRequestMockProvider.java b/src/test/java/de/tum/cit/aet/artemis/core/connector/IrisRequestMockProvider.java index 0d903d5a0e17..db8e032aca21 100644 --- a/src/test/java/de/tum/cit/aet/artemis/core/connector/IrisRequestMockProvider.java +++ b/src/test/java/de/tum/cit/aet/artemis/core/connector/IrisRequestMockProvider.java @@ -32,6 +32,7 @@ import de.tum.cit.aet.artemis.iris.domain.settings.IrisSubSettingsType; import de.tum.cit.aet.artemis.iris.service.pyris.dto.PyrisHealthStatusDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.PyrisVariantDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.chat.course.PyrisCourseChatPipelineExecutionDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.chat.exercise.PyrisExerciseChatPipelineExecutionDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.chat.textexercise.PyrisTextExerciseChatPipelineExecutionDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.competency.PyrisCompetencyExtractionPipelineExecutionDTO; @@ -179,6 +180,35 @@ public void mockDeletionWebhookRunResponse(Consumer responseConsumer) { + mockServer.expect(ExpectedCount.max(2), requestTo(pipelinesApiURL + "/tutor-chat/default/run?event=build_failed")).andExpect(method(HttpMethod.POST)) + .andRespond(request -> { + var mockRequest = (MockClientHttpRequest) request; + var dto = mapper.readValue(mockRequest.getBodyAsString(), PyrisExerciseChatPipelineExecutionDTO.class); + responseConsumer.accept(dto); + return MockRestResponseCreators.withRawStatus(HttpStatus.ACCEPTED.value()).createResponse(request); + }); + } + + public void mockProgressStalledEventRunResponse(Consumer responseConsumer) { + mockServer.expect(ExpectedCount.max(2), requestTo(pipelinesApiURL + "/tutor-chat/default/run?event=progress_stalled")).andExpect(method(HttpMethod.POST)) + .andRespond(request -> { + var mockRequest = (MockClientHttpRequest) request; + var dto = mapper.readValue(mockRequest.getBodyAsString(), PyrisCourseChatPipelineExecutionDTO.class); + responseConsumer.accept(dto); + return MockRestResponseCreators.withRawStatus(HttpStatus.ACCEPTED.value()).createResponse(request); + }); + } + + public void mockJolEventRunResponse(Consumer responseConsumer) { + mockServer.expect(ExpectedCount.once(), requestTo(pipelinesApiURL + "/course-chat/default/run?event=jol")).andExpect(method(HttpMethod.POST)).andRespond(request -> { + var mockRequest = (MockClientHttpRequest) request; + var dto = mapper.readValue(mockRequest.getBodyAsString(), PyrisCourseChatPipelineExecutionDTO.class); + responseConsumer.accept(dto); + return MockRestResponseCreators.withRawStatus(HttpStatus.ACCEPTED.value()).createResponse(request); + }); + } + public void mockRunError(int httpStatus) { // @formatter:off mockServer diff --git a/src/test/java/de/tum/cit/aet/artemis/iris/AbstractIrisIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/iris/AbstractIrisIntegrationTest.java index 7820ffe46931..b7866bd171b3 100644 --- a/src/test/java/de/tum/cit/aet/artemis/iris/AbstractIrisIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/iris/AbstractIrisIntegrationTest.java @@ -93,6 +93,7 @@ protected void activateIrisFor(Exercise exercise) { var exerciseSettings = irisSettingsService.getDefaultSettingsFor(exercise); activateSubSettings(exerciseSettings.getIrisChatSettings()); activateSubSettings(exerciseSettings.getIrisTextExerciseChatSettings()); + irisSettingsRepository.save(exerciseSettings); } diff --git a/src/test/java/de/tum/cit/aet/artemis/iris/PyrisConnectorServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/iris/PyrisConnectorServiceTest.java index a2a6c28aa6c3..67f18be698ef 100644 --- a/src/test/java/de/tum/cit/aet/artemis/iris/PyrisConnectorServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/iris/PyrisConnectorServiceTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.List; +import java.util.Optional; import java.util.stream.Stream; import org.junit.jupiter.params.ParameterizedTest; @@ -43,7 +44,7 @@ private static Stream irisExceptions() { void testExceptionV2(int httpStatus, Class exceptionClass) { irisRequestMockProvider.mockRunError(httpStatus); - assertThatThrownBy(() -> pyrisConnectorService.executePipeline("tutor-chat", "default", null)).isInstanceOf(exceptionClass); + assertThatThrownBy(() -> pyrisConnectorService.executePipeline("tutor-chat", "default", null, Optional.empty())).isInstanceOf(exceptionClass); } @ParameterizedTest diff --git a/src/test/java/de/tum/cit/aet/artemis/iris/PyrisEventSystemIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/iris/PyrisEventSystemIntegrationTest.java new file mode 100644 index 000000000000..62bc019b38a8 --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/iris/PyrisEventSystemIntegrationTest.java @@ -0,0 +1,363 @@ +package de.tum.cit.aet.artemis.iris; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.time.ZonedDateTime; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.eclipse.jgit.api.errors.GitAPIException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.test.context.support.WithMockUser; + +import de.tum.cit.aet.artemis.assessment.domain.AssessmentType; +import de.tum.cit.aet.artemis.assessment.domain.Result; +import de.tum.cit.aet.artemis.atlas.competency.util.CompetencyUtilService; +import de.tum.cit.aet.artemis.atlas.domain.competency.Competency; +import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyJol; +import de.tum.cit.aet.artemis.core.domain.Course; +import de.tum.cit.aet.artemis.core.domain.User; +import de.tum.cit.aet.artemis.core.exception.AccessForbiddenAlertException; +import de.tum.cit.aet.artemis.core.user.util.UserUtilService; +import de.tum.cit.aet.artemis.exercise.domain.SubmissionType; +import de.tum.cit.aet.artemis.exercise.participation.util.ParticipationFactory; +import de.tum.cit.aet.artemis.exercise.participation.util.ParticipationUtilService; +import de.tum.cit.aet.artemis.exercise.team.TeamUtilService; +import de.tum.cit.aet.artemis.exercise.test_repository.SubmissionTestRepository; +import de.tum.cit.aet.artemis.iris.domain.settings.event.IrisEventType; +import de.tum.cit.aet.artemis.iris.repository.IrisSettingsRepository; +import de.tum.cit.aet.artemis.iris.service.pyris.PyrisEventProcessingException; +import de.tum.cit.aet.artemis.iris.service.pyris.PyrisEventService; +import de.tum.cit.aet.artemis.iris.service.pyris.PyrisJobService; +import de.tum.cit.aet.artemis.iris.service.pyris.PyrisStatusUpdateService; +import de.tum.cit.aet.artemis.iris.service.pyris.UnsupportedPyrisEventException; +import de.tum.cit.aet.artemis.iris.service.pyris.event.NewResultEvent; +import de.tum.cit.aet.artemis.iris.service.pyris.event.PyrisEvent; +import de.tum.cit.aet.artemis.iris.service.session.IrisExerciseChatSessionService; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseStudentParticipation; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingSubmission; +import de.tum.cit.aet.artemis.programming.domain.ProjectType; +import de.tum.cit.aet.artemis.programming.domain.SolutionProgrammingExerciseParticipation; +import de.tum.cit.aet.artemis.programming.domain.TemplateProgrammingExerciseParticipation; +import de.tum.cit.aet.artemis.programming.util.ProgrammingExerciseUtilService; + +class PyrisEventSystemIntegrationTest extends AbstractIrisIntegrationTest { + + private static final String TEST_PREFIX = "pyriseventsystemintegration"; + + @Autowired + protected PyrisStatusUpdateService pyrisStatusUpdateService; + + @Autowired + protected PyrisJobService pyrisJobService; + + @Autowired + protected IrisSettingsRepository irisSettingsRepository; + + @Autowired + private ProgrammingExerciseUtilService programmingExerciseUtilService; + + @Autowired + private SubmissionTestRepository submissionRepository; + + @Autowired + private PyrisEventService pyrisEventService; + + @Autowired + private ParticipationUtilService participationUtilService; + + @Autowired + private UserUtilService userUtilService; + + @Autowired + private CompetencyUtilService competencyUtilService; + + @Autowired + private TeamUtilService teamUtilService; + + private ProgrammingExercise exercise; + + private Course course; + + private ProgrammingExerciseStudentParticipation studentParticipation; + + private AtomicBoolean pipelineDone; + + private Competency competency; + + @BeforeEach + void initTestCase() throws GitAPIException, IOException, URISyntaxException { + userUtilService.addUsers(TEST_PREFIX, 2, 0, 0, 1); + + course = programmingExerciseUtilService.addCourseWithOneProgrammingExercise(); + competency = competencyUtilService.createCompetency(course); + exercise = exerciseUtilService.getFirstExerciseWithType(course, ProgrammingExercise.class); + String projectKey = exercise.getProjectKey(); + exercise.setProjectType(ProjectType.PLAIN_GRADLE); + exercise.setTestRepositoryUri(localVCBaseUrl + "/git/" + projectKey + "/" + projectKey.toLowerCase() + "-tests.git"); + programmingExerciseRepository.save(exercise); + exercise = programmingExerciseRepository.findWithAllParticipationsAndBuildConfigById(exercise.getId()).orElseThrow(); + + // Set the correct repository URIs for the template and the solution participation. + String templateRepositorySlug = projectKey.toLowerCase() + "-exercise"; + TemplateProgrammingExerciseParticipation templateParticipation = exercise.getTemplateParticipation(); + templateParticipation.setRepositoryUri(localVCBaseUrl + "/git/" + projectKey + "/" + templateRepositorySlug + ".git"); + templateProgrammingExerciseParticipationRepository.save(templateParticipation); + String solutionRepositorySlug = projectKey.toLowerCase() + "-solution"; + SolutionProgrammingExerciseParticipation solutionParticipation = exercise.getSolutionParticipation(); + solutionParticipation.setRepositoryUri(localVCBaseUrl + "/git/" + projectKey + "/" + solutionRepositorySlug + ".git"); + solutionProgrammingExerciseParticipationRepository.save(solutionParticipation); + + String assignmentRepositorySlug = projectKey.toLowerCase() + "-" + TEST_PREFIX + "student1"; + + // Add a participation for student1. + studentParticipation = participationUtilService.addStudentParticipationForProgrammingExercise(exercise, TEST_PREFIX + "student1"); + studentParticipation.setRepositoryUri(String.format(localVCBaseUrl + "/git/%s/%s.git", projectKey, assignmentRepositorySlug)); + studentParticipation.setBranch(defaultBranch); + + programmingExerciseStudentParticipationRepository.save(studentParticipation); + + // Prepare the repositories. + localVCLocalCITestService.createAndConfigureLocalRepository(projectKey, templateRepositorySlug); + localVCLocalCITestService.createAndConfigureLocalRepository(projectKey, projectKey.toLowerCase() + "-tests"); + localVCLocalCITestService.createAndConfigureLocalRepository(projectKey, solutionRepositorySlug); + localVCLocalCITestService.createAndConfigureLocalRepository(projectKey, assignmentRepositorySlug); + + // Check that the repository folders were created in the file system for all base repositories. + localVCLocalCITestService.verifyRepositoryFoldersExist(exercise, localVCBasePath); + + activateIrisGlobally(); + activateIrisFor(course); + activateIrisFor(exercise); + + pipelineDone = new AtomicBoolean(false); + } + + private Result createSubmission(ProgrammingExerciseStudentParticipation studentParticipation, int score, boolean buildFailed) { + ProgrammingSubmission submission = new ProgrammingSubmission(); + submission.setBuildFailed(buildFailed); + submission.setType(SubmissionType.MANUAL); + submission.setParticipation(studentParticipation); + submission = submissionRepository.saveAndFlush(submission); + + Result result = ParticipationFactory.generateResult(true, score); + result.setSubmission(submission); + result.completionDate(ZonedDateTime.now()); + result.setAssessmentType(AssessmentType.AUTOMATIC); + submission.addResult(result); + submissionRepository.saveAndFlush(submission); + + return resultRepository.save(result); + } + + private Result createSubmissionWithScore(ProgrammingExerciseStudentParticipation studentParticipation, int score) { + return createSubmission(studentParticipation, score, false); + } + + private Result createFailingSubmission(ProgrammingExerciseStudentParticipation studentParticipation) { + return createSubmission(studentParticipation, 0, true); + } + + private ProgrammingExerciseStudentParticipation createTeamParticipation(User owner) { + var team = teamUtilService.addTeamForExercise(exercise, owner); + var teamParticipation = participationUtilService.addTeamParticipationForProgrammingExercise(exercise, team); + teamParticipation + .setRepositoryUri(String.format(localVCBaseUrl + "/git/%s/%s-%s.git", exercise.getProjectKey(), exercise.getProjectKey().toLowerCase(), team.getShortName())); + return programmingExerciseStudentParticipationRepository.save(teamParticipation); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testShouldFireProgressStalledEvent() { + var irisSession = irisExerciseChatSessionService.createChatSessionForProgrammingExercise(exercise, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); + // Create three submissions for the student. + createSubmissionWithScore(studentParticipation, 40); + createSubmissionWithScore(studentParticipation, 40); + var result = createSubmissionWithScore(studentParticipation, 40); + irisRequestMockProvider.mockProgressStalledEventRunResponse((dto) -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + pipelineDone.set(true); + }); + + pyrisEventService.trigger(new NewResultEvent(result)); + verify(irisExerciseChatSessionService, times(1)).onNewResult(eq(result)); + + await().atMost(2, TimeUnit.SECONDS).until(() -> pipelineDone.get()); + + verify(pyrisPipelineService, times(1)).executeExerciseChatPipeline(eq("default"), eq(Optional.ofNullable((ProgrammingSubmission) result.getSubmission())), eq(exercise), + eq(irisSession), eq(Optional.of("progress_stalled"))); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testShouldFireBuildFailedEvent() { + var irisSession = irisExerciseChatSessionService.createChatSessionForProgrammingExercise(exercise, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); + // Create a failing submissions for the student. + var result = createFailingSubmission(studentParticipation); + irisRequestMockProvider.mockBuildFailedRunResponse((dto) -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + pipelineDone.set(true); + }); + + pyrisEventService.trigger(new NewResultEvent(result)); + verify(irisExerciseChatSessionService, times(1)).onBuildFailure(eq(result)); + + await().atMost(2, TimeUnit.SECONDS).until(() -> pipelineDone.get()); + + verify(pyrisPipelineService, times(1)).executeExerciseChatPipeline(eq("default"), eq(Optional.ofNullable((ProgrammingSubmission) result.getSubmission())), eq(exercise), + eq(irisSession), eq(Optional.of("build_failed"))); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testShouldFireJolEvent() { + var irisSession = irisCourseChatSessionService.createSession(course, userUtilService.getUserByLogin(TEST_PREFIX + "student1"), false); + var jolValue = 3; + irisRequestMockProvider.mockJolEventRunResponse((dto) -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + pipelineDone.set(true); + }); + competencyJolService.setJudgementOfLearning(competency.getId(), userUtilService.getUserByLogin(TEST_PREFIX + "student1").getId(), (short) jolValue); + + await().atMost(2, TimeUnit.SECONDS).until(() -> pipelineDone.get()); + + verify(irisCourseChatSessionService, times(1)).onJudgementOfLearningSet(any(CompetencyJol.class)); + verify(pyrisPipelineService, times(1)).executeCourseChatPipeline(eq("default"), eq(irisSession), any(CompetencyJol.class)); + + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testShouldThrowUnsupportedEventException() { + assertThatExceptionOfType(UnsupportedPyrisEventException.class).isThrownBy(() -> pyrisEventService.trigger(new PyrisEvent() { + + @Override + public void handleEvent(IrisExerciseChatSessionService service) { + // Do nothing + } + })).withMessageStartingWith("Unsupported event"); + + } + + @Test() + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testShouldNotFireProgressStalledEventWithEventDisabled() { + // Find settings for the current exercise + var settings = irisSettingsRepository.findExerciseSettings(exercise.getId()).orElseThrow(); + settings.getIrisChatSettings().setDisabledProactiveEvents(new TreeSet<>(Set.of(IrisEventType.PROGRESS_STALLED.name().toLowerCase()))); + irisSettingsRepository.save(settings); + + createSubmissionWithScore(studentParticipation, 40); + createSubmissionWithScore(studentParticipation, 40); + var result = createSubmissionWithScore(studentParticipation, 40); + assertThatExceptionOfType(AccessForbiddenAlertException.class).isThrownBy(() -> pyrisEventService.trigger(new NewResultEvent(result))); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testShouldNotFireBuildFailedEventWhenEventSettingDisabled() { + // Find settings for the current exercise + var settings = irisSettingsRepository.findExerciseSettings(exercise.getId()).orElseThrow(); + settings.getIrisChatSettings().setDisabledProactiveEvents(new TreeSet<>(Set.of(IrisEventType.BUILD_FAILED.name().toLowerCase()))); + irisSettingsRepository.save(settings); + + irisExerciseChatSessionService.createChatSessionForProgrammingExercise(exercise, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); + // Create a failing submission for the student. + var result = createFailingSubmission(studentParticipation); + assertThatExceptionOfType(AccessForbiddenAlertException.class).isThrownBy(() -> pyrisEventService.trigger(new NewResultEvent(result))); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testShouldShouldNotFireProgressStalledEventWithExistingSuccessfulSubmission() { + irisExerciseChatSessionService.createChatSessionForProgrammingExercise(exercise, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); + irisRequestMockProvider.mockProgressStalledEventRunResponse((dto) -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + pipelineDone.set(true); + }); + createSubmissionWithScore(studentParticipation, 100); + var result = createSubmissionWithScore(studentParticipation, 50); + + pyrisEventService.trigger(new NewResultEvent(result)); + + await().atMost(2, TimeUnit.SECONDS); + + result = createSubmissionWithScore(studentParticipation, 50); + + pyrisEventService.trigger(new NewResultEvent(result)); + await().atMost(2, TimeUnit.SECONDS); + + verify(irisExerciseChatSessionService, times(2)).onNewResult(any(Result.class)); + verify(pyrisPipelineService, times(0)).executeExerciseChatPipeline(any(), any(), any(), any(), any()); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testShouldNotFireProgressStalledEventWithLessThanThreeSubmissions() { + irisExerciseChatSessionService.createChatSessionForProgrammingExercise(exercise, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); + // Create two failing submissions for the student. + createSubmissionWithScore(studentParticipation, 20); + var result = createSubmissionWithScore(studentParticipation, 20); + + pyrisEventService.trigger(new NewResultEvent(result)); + + verify(irisExerciseChatSessionService, times(1)).onNewResult(any(Result.class)); + verify(pyrisPipelineService, times(0)).executeExerciseChatPipeline(any(), any(), any(), any(), any()); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testShouldNotFireProgressStalledEventWithIncreasingScores() { + irisExerciseChatSessionService.createChatSessionForProgrammingExercise(exercise, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); + // Create three submissions with increasing scores for the student. + createSubmissionWithScore(studentParticipation, 20); + createSubmissionWithScore(studentParticipation, 30); + var result = createSubmissionWithScore(studentParticipation, 40); + + pyrisEventService.trigger(new NewResultEvent(result)); + + verify(irisExerciseChatSessionService, times(1)).onNewResult(any(Result.class)); + verify(pyrisPipelineService, times(0)).executeExerciseChatPipeline(any(), any(), any(), any(), any()); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testShouldNotFireBuildFailedEventForTeamSubmission() { + var owner = userUtilService.getUserByLogin(TEST_PREFIX + "student1"); + var teamParticipation = createTeamParticipation(owner); + irisExerciseChatSessionService.createChatSessionForProgrammingExercise(exercise, owner); + var result = createFailingSubmission(teamParticipation); + + assertThatExceptionOfType(PyrisEventProcessingException.class).isThrownBy(() -> irisExerciseChatSessionService.onBuildFailure(result)) + .withMessageStartingWith("Build failure event is not supported for team participations"); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testShouldNotFireProgressStalledEventForTeamSubmission() { + var owner = userUtilService.getUserByLogin(TEST_PREFIX + "student1"); + var teamParticipation = createTeamParticipation(owner); + irisExerciseChatSessionService.createChatSessionForProgrammingExercise(exercise, owner); + createSubmissionWithScore(teamParticipation, 40); + createSubmissionWithScore(teamParticipation, 40); + var result = createSubmissionWithScore(teamParticipation, 40); + + assertThatExceptionOfType(PyrisEventProcessingException.class).isThrownBy(() -> irisExerciseChatSessionService.onNewResult(result)) + .withMessageStartingWith("Progress stalled event is not supported for team participations"); + } + +} diff --git a/src/test/java/de/tum/cit/aet/artemis/iris/settings/IrisSettingsIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/iris/settings/IrisSettingsIntegrationTest.java index 7912b732a639..6e7939cbe271 100644 --- a/src/test/java/de/tum/cit/aet/artemis/iris/settings/IrisSettingsIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/iris/settings/IrisSettingsIntegrationTest.java @@ -5,6 +5,7 @@ import java.time.ZonedDateTime; import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.SortedSet; import java.util.TreeSet; import java.util.stream.Collectors; @@ -32,6 +33,7 @@ import de.tum.cit.aet.artemis.iris.domain.settings.IrisLectureIngestionSubSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisTextExerciseChatSubSettings; +import de.tum.cit.aet.artemis.iris.domain.settings.event.IrisEventType; import de.tum.cit.aet.artemis.iris.dto.IrisCombinedSettingsDTO; import de.tum.cit.aet.artemis.iris.repository.IrisSettingsRepository; import de.tum.cit.aet.artemis.iris.repository.IrisSubSettingsRepository; @@ -144,10 +146,10 @@ void getCourseSettingsAsUser() throws Exception { request.get("/api/courses/" + course.getId() + "/raw-iris-settings", HttpStatus.FORBIDDEN, IrisSettings.class); var loadedSettings = request.get("/api/courses/" + course.getId() + "/iris-settings", HttpStatus.OK, IrisCombinedSettingsDTO.class); - - assertThat(loadedSettings).isNotNull().usingRecursiveComparison().ignoringCollectionOrderInFields("irisChatSettings.allowedVariants", - "irisLectureIngestionSettings.allowedVariants", "irisCompetencyGenerationSettings.allowedVariants").ignoringFields("id") - .isEqualTo(irisSettingsService.getCombinedIrisSettingsFor(course, true)); + assertThat(loadedSettings) + .isNotNull().usingRecursiveComparison().ignoringCollectionOrderInFields("irisChatSettings.allowedVariants", "irisChatSettings.disabledProactiveEvents", + "irisLectureIngestionSettings.allowedVariants", "irisCompetencyGenerationSettings.allowedVariants") + .ignoringFields("id").isEqualTo(irisSettingsService.getCombinedIrisSettingsFor(course, true)); } @Test @@ -247,6 +249,26 @@ void updateCourseSettings3() throws Exception { "irisLectureIngestionSettings.id", "irisCompetencyGenerationSettings.id", "irisCourseChatSettings.id").isEqualTo(courseSettings); } + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void updateCourseSettings4() throws Exception { + activateIrisGlobally(); + activateIrisFor(course); + course = courseRepository.findByIdElseThrow(course.getId()); + + var loadedSettings1 = request.get("/api/courses/" + course.getId() + "/raw-iris-settings", HttpStatus.OK, IrisSettings.class); + + loadedSettings1.getIrisChatSettings().setDisabledProactiveEvents(new TreeSet<>(Set.of("PROGRESS_STALLED"))); + + var updatedSettings = request.putWithResponseBody("/api/courses/" + course.getId() + "/raw-iris-settings", loadedSettings1, IrisSettings.class, HttpStatus.OK); + var loadedSettings2 = request.get("/api/courses/" + course.getId() + "/raw-iris-settings", HttpStatus.OK, IrisSettings.class); + + // Proactive events should have been updated + assertThat(updatedSettings).isNotNull().usingRecursiveComparison().ignoringFields("course").isEqualTo(loadedSettings2); + assertThat(updatedSettings.getIrisChatSettings().getDisabledProactiveEvents()).containsExactly("PROGRESS_STALLED"); + assertThat(loadedSettings1).isNotNull().usingRecursiveComparison().ignoringFields("course").isEqualTo(loadedSettings2); + } + /** * This test check if exercises get correctly enabled and disabled based on the categories in the course settings. * @@ -408,4 +430,36 @@ void updateProgrammingExerciseSettings3() throws Exception { assertThat(updatedSettings).isNotNull().isEqualTo(loadedSettings1); assertThat(loadedSettings1).usingRecursiveComparison().ignoringFields("id", "exercise", "irisChatSettings.id", "irisChatSettings.template.id").isEqualTo(exerciseSettings); } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void updateProgrammingExerciseSettings4() throws Exception { + activateIrisGlobally(); + course = courseRepository.findByIdElseThrow(course.getId()); + + var courseSettings = new IrisCourseSettings(); + courseSettings.setCourse(course); + courseSettings.setIrisChatSettings(new IrisChatSubSettings()); + courseSettings.getIrisChatSettings().setEnabled(true); + courseSettings.getIrisChatSettings().setSelectedVariant(null); + courseSettings.getIrisChatSettings().setDisabledProactiveEvents(new TreeSet<>(Set.of(IrisEventType.PROGRESS_STALLED.name().toLowerCase()))); + + request.putWithResponseBody("/api/courses/" + course.getId() + "/raw-iris-settings", courseSettings, IrisSettings.class, HttpStatus.OK); + request.get("/api/courses/" + course.getId() + "/raw-iris-settings", HttpStatus.OK, IrisSettings.class); + + programmingExercise = programmingExerciseRepository.findByIdElseThrow(programmingExercise.getId()); + + var exerciseSettings = new IrisExerciseSettings(); + exerciseSettings.setExercise(programmingExercise); + exerciseSettings.setIrisChatSettings(new IrisChatSubSettings()); + exerciseSettings.getIrisChatSettings().setEnabled(true); + exerciseSettings.getIrisChatSettings().setSelectedVariant(null); + exerciseSettings.getIrisChatSettings().setDisabledProactiveEvents(new TreeSet<>(Set.of(IrisEventType.BUILD_FAILED.name().toLowerCase()))); + + request.putWithResponseBody("/api/exercises/" + programmingExercise.getId() + "/raw-iris-settings", exerciseSettings, IrisSettings.class, HttpStatus.OK); + var loadedExerciseSettings = request.get("/api/exercises/" + programmingExercise.getId() + "/iris-settings", HttpStatus.OK, IrisCombinedSettingsDTO.class); + // Combined settings should include the union of the disabled course events and disabled exercise events + assertThat(loadedExerciseSettings.irisChatSettings().disabledProactiveEvents()).isNotNull() + .isEqualTo(new TreeSet<>(Set.of(IrisEventType.PROGRESS_STALLED.name().toLowerCase(), IrisEventType.BUILD_FAILED.name().toLowerCase()))); + } } diff --git a/src/test/java/de/tum/cit/aet/artemis/shared/base/AbstractSpringIntegrationLocalCILocalVCTest.java b/src/test/java/de/tum/cit/aet/artemis/shared/base/AbstractSpringIntegrationLocalCILocalVCTest.java index 4681cfe28735..f0dfa2fe983c 100644 --- a/src/test/java/de/tum/cit/aet/artemis/shared/base/AbstractSpringIntegrationLocalCILocalVCTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/shared/base/AbstractSpringIntegrationLocalCILocalVCTest.java @@ -32,6 +32,7 @@ import com.github.dockerjava.api.DockerClient; +import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyJolService; import de.tum.cit.aet.artemis.communication.service.notifications.GroupNotificationScheduleService; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.domain.User; @@ -40,6 +41,9 @@ import de.tum.cit.aet.artemis.core.user.util.UserUtilService; import de.tum.cit.aet.artemis.exam.service.ExamLiveEventsService; import de.tum.cit.aet.artemis.exercise.domain.Team; +import de.tum.cit.aet.artemis.iris.service.pyris.PyrisPipelineService; +import de.tum.cit.aet.artemis.iris.service.session.IrisCourseChatSessionService; +import de.tum.cit.aet.artemis.iris.service.session.IrisExerciseChatSessionService; import de.tum.cit.aet.artemis.programming.domain.AbstractBaseProgrammingExerciseParticipation; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseParticipation; @@ -134,6 +138,18 @@ public abstract class AbstractSpringIntegrationLocalCILocalVCTest extends Abstra @MockitoSpyBean protected GroupNotificationScheduleService groupNotificationScheduleService; + @MockitoSpyBean + protected IrisCourseChatSessionService irisCourseChatSessionService; + + @MockitoSpyBean + protected CompetencyJolService competencyJolService; + + @MockitoSpyBean + protected PyrisPipelineService pyrisPipelineService; + + @MockitoSpyBean + protected IrisExerciseChatSessionService irisExerciseChatSessionService; + @Value("${artemis.version-control.url}") protected URL localVCBaseUrl; diff --git a/src/test/javascript/spec/component/iris/settings/iris-common-sub-settings-update.component.spec.ts b/src/test/javascript/spec/component/iris/settings/iris-common-sub-settings-update.component.spec.ts index d43efbcc98e4..6f9cc5a26bde 100644 --- a/src/test/javascript/spec/component/iris/settings/iris-common-sub-settings-update.component.spec.ts +++ b/src/test/javascript/spec/component/iris/settings/iris-common-sub-settings-update.component.spec.ts @@ -14,6 +14,7 @@ import { of } from 'rxjs'; import { ExerciseCategory } from 'app/entities/exercise-category.model'; import { CourseManagementService } from 'app/course/manage/course-management.service'; import { HttpResponse } from '@angular/common/http'; +import { MockJhiTranslateDirective } from '../../../helpers/mocks/directive/mock-jhi-translate-directive.directive'; function baseSettings() { const irisSubSettings = new IrisChatSubSettings(); @@ -43,7 +44,7 @@ describe('IrisCommonSubSettingsUpdateComponent Component', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [ArtemisTestModule, FormsModule, MockDirective(NgbTooltip), MockPipe(ArtemisTranslatePipe)], + imports: [ArtemisTestModule, FormsModule, MockDirective(NgbTooltip), MockPipe(ArtemisTranslatePipe), MockJhiTranslateDirective], declarations: [IrisCommonSubSettingsUpdateComponent], }) .compileComponents() diff --git a/src/test/javascript/spec/component/iris/settings/iris-course-settings-update.component.spec.ts b/src/test/javascript/spec/component/iris/settings/iris-course-settings-update.component.spec.ts index 6271fd91f52e..12f1ebfe860a 100644 --- a/src/test/javascript/spec/component/iris/settings/iris-course-settings-update.component.spec.ts +++ b/src/test/javascript/spec/component/iris/settings/iris-course-settings-update.component.spec.ts @@ -13,6 +13,7 @@ import { IrisCourseSettingsUpdateComponent } from 'app/iris/settings/iris-course import { By } from '@angular/platform-browser'; import { IrisSettings } from 'app/entities/iris/settings/iris-settings.model'; import { HttpResponse } from '@angular/common/http'; +import { MockJhiTranslateDirective } from '../../../helpers/mocks/directive/mock-jhi-translate-directive.directive'; describe('IrisCourseSettingsUpdateComponent Component', () => { let comp: IrisCourseSettingsUpdateComponent; @@ -26,7 +27,7 @@ describe('IrisCourseSettingsUpdateComponent Component', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [ArtemisTestModule], + imports: [ArtemisTestModule, MockJhiTranslateDirective], declarations: [ IrisCourseSettingsUpdateComponent, IrisSettingsUpdateComponent, @@ -56,6 +57,10 @@ describe('IrisCourseSettingsUpdateComponent Component', () => { jest.restoreAllMocks(); }); + it('should create IrisCourseSettingsUpdateComponent', () => { + expect(comp).toBeDefined(); + }); + it('Setup works correctly', () => { fixture.detectChanges(); expect(paramsSpy).toHaveBeenCalledOnce(); diff --git a/src/test/javascript/spec/component/iris/settings/iris-global-settings-update.component.spec.ts b/src/test/javascript/spec/component/iris/settings/iris-global-settings-update.component.spec.ts index 9c325fbea0d0..c2837bccd6b4 100644 --- a/src/test/javascript/spec/component/iris/settings/iris-global-settings-update.component.spec.ts +++ b/src/test/javascript/spec/component/iris/settings/iris-global-settings-update.component.spec.ts @@ -47,6 +47,10 @@ describe('IrisGlobalSettingsUpdateComponent Component', () => { jest.restoreAllMocks(); }); + it('should create IrisGlobalSettingsUpdateComponent', () => { + expect(comp).toBeDefined(); + }); + it('Setup works correctly', () => { fixture.detectChanges(); expect(comp.settingsUpdateComponent).toBeTruthy(); diff --git a/src/test/javascript/spec/component/iris/settings/iris-settings-update-component.spec.ts b/src/test/javascript/spec/component/iris/settings/iris-settings-update-component.spec.ts index d07481ba739e..bfea843b7c8b 100644 --- a/src/test/javascript/spec/component/iris/settings/iris-settings-update-component.spec.ts +++ b/src/test/javascript/spec/component/iris/settings/iris-settings-update-component.spec.ts @@ -12,6 +12,8 @@ import { IrisSettingsService } from 'app/iris/settings/shared/iris-settings.serv import { of } from 'rxjs'; import { IrisCourseSettingsUpdateComponent } from 'app/iris/settings/iris-course-settings-update/iris-course-settings-update.component'; import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { NgbTooltipMockDirective } from '../../../helpers/mocks/directive/ngbTooltipMocks.module'; +import { MockJhiTranslateDirective } from '../../../helpers/mocks/directive/mock-jhi-translate-directive.directive'; describe('IrisSettingsUpdateComponent', () => { let component: IrisSettingsUpdateComponent; @@ -20,7 +22,7 @@ describe('IrisSettingsUpdateComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [ArtemisTestModule], + imports: [ArtemisTestModule, NgbTooltipMockDirective, MockJhiTranslateDirective], declarations: [ IrisCourseSettingsUpdateComponent, IrisSettingsUpdateComponent, @@ -41,7 +43,6 @@ describe('IrisSettingsUpdateComponent', () => { .then(() => { fixture = TestBed.createComponent(IrisSettingsUpdateComponent); component = fixture.componentInstance; - const irisSettingsService = TestBed.inject(IrisSettingsService); getVariantsSpy = jest.spyOn(irisSettingsService, 'getVariantsForFeature').mockReturnValue(of(mockVariants())); }); diff --git a/src/test/javascript/spec/component/iris/settings/mock-settings.ts b/src/test/javascript/spec/component/iris/settings/mock-settings.ts index ef7c75f48a9e..5662521df19a 100644 --- a/src/test/javascript/spec/component/iris/settings/mock-settings.ts +++ b/src/test/javascript/spec/component/iris/settings/mock-settings.ts @@ -3,6 +3,7 @@ import { IrisChatSubSettings, IrisCompetencyGenerationSubSettings, IrisCourseChatSubSettings, + IrisEventType, IrisLectureIngestionSubSettings, IrisTextExerciseChatSubSettings, } from 'app/entities/iris/settings/iris-sub-settings.model'; @@ -12,6 +13,7 @@ export function mockSettings() { const mockChatSettings = new IrisChatSubSettings(); mockChatSettings.id = 1; mockChatSettings.enabled = true; + mockChatSettings.disabledProactiveEvents = []; const mockTextExerciseChatSettings = new IrisTextExerciseChatSubSettings(); mockTextExerciseChatSettings.id = 13; mockTextExerciseChatSettings.enabled = true; diff --git a/src/test/javascript/spec/component/iris/ui/exercise-chatbot-button.component.spec.ts b/src/test/javascript/spec/component/iris/ui/exercise-chatbot-button.component.spec.ts index 0e78bb39e27a..df2d6bff72f3 100644 --- a/src/test/javascript/spec/component/iris/ui/exercise-chatbot-button.component.spec.ts +++ b/src/test/javascript/spec/component/iris/ui/exercise-chatbot-button.component.spec.ts @@ -1,9 +1,7 @@ -import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { ComponentFixture, TestBed, fakeAsync, flush, tick } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; import { Overlay } from '@angular/cdk/overlay'; -import { FormsModule } from '@angular/forms'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; -import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { MockComponent, MockPipe, MockProvider } from 'ng-mocks'; import { AccountService } from 'app/core/auth/account.service'; import { Subject, of } from 'rxjs'; @@ -20,6 +18,9 @@ import { IrisStatusService } from 'app/iris/iris-status.service'; import { UserService } from 'app/core/user/user.service'; import dayjs from 'dayjs/esm'; import { provideHttpClient } from '@angular/common/http'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { HtmlForMarkdownPipe } from '../../../../../../main/webapp/app/shared/pipes/html-for-markdown.pipe'; +import { ArtemisTestModule } from '../../../test.module'; describe('ExerciseChatbotButtonComponent', () => { let component: IrisExerciseChatbotButtonComponent; @@ -69,8 +70,8 @@ describe('ExerciseChatbotButtonComponent', () => { } as unknown as Overlay; await TestBed.configureTestingModule({ - imports: [FormsModule, FontAwesomeModule], - declarations: [IrisExerciseChatbotButtonComponent, MockComponent(IrisLogoComponent), MockPipe(ArtemisTranslatePipe)], + imports: [ArtemisTestModule, FontAwesomeModule, NoopAnimationsModule, MockPipe(HtmlForMarkdownPipe)], + declarations: [IrisExerciseChatbotButtonComponent, MockComponent(IrisLogoComponent)], providers: [ provideHttpClient(), provideHttpClientTesting(), @@ -161,6 +162,7 @@ describe('ExerciseChatbotButtonComponent', () => { // then const unreadIndicatorElement: HTMLInputElement = fixture.debugElement.nativeElement.querySelector('.unread-indicator'); expect(unreadIndicatorElement).not.toBeNull(); + flush(); })); it('should not show new message indicator when chatbot is open', fakeAsync(() => { @@ -177,5 +179,6 @@ describe('ExerciseChatbotButtonComponent', () => { // then const unreadIndicatorElement: HTMLInputElement = fixture.debugElement.nativeElement.querySelector('.unread-indicator'); expect(unreadIndicatorElement).toBeNull(); + flush(); })); }); diff --git a/src/test/javascript/spec/helpers/mocks/directive/mock-jhi-translate-directive.directive.ts b/src/test/javascript/spec/helpers/mocks/directive/mock-jhi-translate-directive.directive.ts new file mode 100644 index 000000000000..729726daf0e3 --- /dev/null +++ b/src/test/javascript/spec/helpers/mocks/directive/mock-jhi-translate-directive.directive.ts @@ -0,0 +1,9 @@ +import { Directive, Input } from '@angular/core'; + +@Directive({ + selector: '[jhiTranslate]', + standalone: true, +}) +export class MockJhiTranslateDirective { + @Input() jhiTranslate: string; +}