From bb052261a269580a37af6f463f56d0596b7977b1 Mon Sep 17 00:00:00 2001 From: Timor Morrien Date: Tue, 10 Dec 2024 14:10:48 +0100 Subject: [PATCH 1/3] Iris: Add course chat settings (#9866) --- build.gradle | 2 +- .../settings/IrisCourseChatSubSettings.java | 44 +++++ .../domain/settings/IrisCourseSettings.java | 14 ++ .../domain/settings/IrisExerciseSettings.java | 11 ++ .../domain/settings/IrisGlobalSettings.java | 14 ++ .../iris/domain/settings/IrisSettings.java | 4 + .../iris/domain/settings/IrisSubSettings.java | 1 + .../domain/settings/IrisSubSettingsType.java | 4 +- .../IrisCombinedCourseChatSubSettingsDTO.java | 13 ++ .../iris/dto/IrisCombinedSettingsDTO.java | 1 + .../session/IrisCourseChatSessionService.java | 8 +- .../service/settings/IrisSettingsService.java | 158 ++++++++++-------- .../settings/IrisSubSettingsService.java | 66 +++++++- .../web/IrisCourseChatSessionResource.java | 2 +- .../changelog/20241119191919_changelog.xml | 18 ++ .../resources/config/liquibase/master.xml | 1 + .../manage/detail/course-detail.component.ts | 1 + .../iris/settings/iris-settings.model.ts | 4 + .../iris/settings/iris-sub-settings.model.ts | 11 +- ...is-common-sub-settings-update.component.ts | 2 +- .../iris-settings-update.component.html | 21 ++- .../iris-settings-update.component.ts | 4 + .../settings/shared/iris-enabled.component.ts | 6 + .../course-dashboard.component.ts | 2 +- src/main/webapp/i18n/de/iris.json | 1 + src/main/webapp/i18n/en/iris.json | 1 + .../iris/AbstractIrisIntegrationTest.java | 9 +- .../settings/IrisSettingsIntegrationTest.java | 31 +++- ...s-course-settings-update.component.spec.ts | 5 +- ...s-global-settings-update.component.spec.ts | 2 +- .../component/iris/settings/mock-settings.ts | 5 + 31 files changed, 368 insertions(+), 98 deletions(-) create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisCourseChatSubSettings.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedCourseChatSubSettingsDTO.java create mode 100644 src/main/resources/config/liquibase/changelog/20241119191919_changelog.xml diff --git a/build.gradle b/build.gradle index 27099c72db3d..4baffa384852 100644 --- a/build.gradle +++ b/build.gradle @@ -80,7 +80,7 @@ spotless { } } importOrderFile "artemis-spotless.importorder" - eclipse("4.28").configFile "artemis-spotless-style.xml" + eclipse("4.33").configFile "artemis-spotless-style.xml" removeUnusedImports() trimTrailingWhitespace() diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisCourseChatSubSettings.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisCourseChatSubSettings.java new file mode 100644 index 000000000000..7428f7feb3b3 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisCourseChatSubSettings.java @@ -0,0 +1,44 @@ +package de.tum.cit.aet.artemis.iris.domain.settings; + +import jakarta.annotation.Nullable; +import jakarta.persistence.Column; +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * An {@link IrisSubSettings} implementation for course chat settings. + * Chat settings notably provide settings for the rate limit. + */ +@Entity +@DiscriminatorValue("COURSE_CHAT") +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public class IrisCourseChatSubSettings extends IrisSubSettings { + + @Nullable + @Column(name = "rate_limit") + private Integer rateLimit; + + @Nullable + @Column(name = "rate_limit_timeframe_hours") + private Integer rateLimitTimeframeHours; + + @Nullable + public Integer getRateLimit() { + return rateLimit; + } + + public void setRateLimit(@Nullable Integer rateLimit) { + this.rateLimit = rateLimit; + } + + @Nullable + public Integer getRateLimitTimeframeHours() { + return rateLimitTimeframeHours; + } + + public void setRateLimitTimeframeHours(@Nullable Integer rateLimitTimeframeHours) { + this.rateLimitTimeframeHours = rateLimitTimeframeHours; + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisCourseSettings.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisCourseSettings.java index fce389a7b95f..8320f2b6d708 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisCourseSettings.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisCourseSettings.java @@ -32,6 +32,10 @@ public class IrisCourseSettings extends IrisSettings { @JoinColumn(name = "iris_text_exercise_chat_settings_id") private IrisTextExerciseChatSubSettings irisTextExerciseChatSettings; + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER, optional = false) + @JoinColumn(name = "iris_course_chat_settings_id") + private IrisCourseChatSubSettings irisCourseChatSettings; + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) @JoinColumn(name = "iris_lecture_ingestion_settings_id") private IrisLectureIngestionSubSettings irisLectureIngestionSettings; @@ -78,6 +82,16 @@ public void setIrisTextExerciseChatSettings(IrisTextExerciseChatSubSettings iris this.irisTextExerciseChatSettings = irisTextExerciseChatSettings; } + @Override + public IrisCourseChatSubSettings getIrisCourseChatSettings() { + return irisCourseChatSettings; + } + + @Override + public void setIrisCourseChatSettings(IrisCourseChatSubSettings irisCourseChatSettings) { + this.irisCourseChatSettings = irisCourseChatSettings; + } + @Override public IrisCompetencyGenerationSubSettings getIrisCompetencyGenerationSettings() { return irisCompetencyGenerationSettings; diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisExerciseSettings.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisExerciseSettings.java index ba095a018808..8048a76e976b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisExerciseSettings.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisExerciseSettings.java @@ -69,6 +69,17 @@ public void setIrisTextExerciseChatSettings(IrisTextExerciseChatSubSettings iris this.irisTextExerciseChatSettings = irisTextExerciseChatSettings; } + @Override + public IrisCourseChatSubSettings getIrisCourseChatSettings() { + // Empty because exercises don't have course chat settings + return null; + } + + @Override + public void setIrisCourseChatSettings(IrisCourseChatSubSettings irisCourseChatSettings) { + // Empty because exercises don't have course chat settings + } + @Override public IrisCompetencyGenerationSubSettings getIrisCompetencyGenerationSettings() { return null; diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisGlobalSettings.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisGlobalSettings.java index ddb156da0038..5531f65584ff 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisGlobalSettings.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisGlobalSettings.java @@ -27,6 +27,10 @@ public class IrisGlobalSettings extends IrisSettings { @JoinColumn(name = "iris_text_exercise_chat_settings_id") private IrisTextExerciseChatSubSettings irisTextExerciseChatSettings; + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER, optional = false) + @JoinColumn(name = "iris_course_chat_settings_id") + private IrisCourseChatSubSettings irisCourseChatSettings; + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER, optional = false) @JoinColumn(name = "iris_lecture_ingestion_settings_id") private IrisLectureIngestionSubSettings irisLectureIngestionSettings; @@ -65,6 +69,16 @@ public void setIrisTextExerciseChatSettings(IrisTextExerciseChatSubSettings iris this.irisTextExerciseChatSettings = irisTextExerciseChatSettings; } + @Override + public IrisCourseChatSubSettings getIrisCourseChatSettings() { + return irisCourseChatSettings; + } + + @Override + public void setIrisCourseChatSettings(IrisCourseChatSubSettings irisCourseChatSettings) { + this.irisCourseChatSettings = irisCourseChatSettings; + } + @Override public IrisCompetencyGenerationSubSettings getIrisCompetencyGenerationSettings() { return irisCompetencyGenerationSettings; diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSettings.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSettings.java index 61b2912d5cf6..d67d49caeab0 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSettings.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSettings.java @@ -49,6 +49,10 @@ public abstract class IrisSettings extends DomainObject { public abstract void setIrisTextExerciseChatSettings(IrisTextExerciseChatSubSettings irisTextExerciseChatSettings); + public abstract IrisCourseChatSubSettings getIrisCourseChatSettings(); + + public abstract void setIrisCourseChatSettings(IrisCourseChatSubSettings irisCourseChatSettings); + public abstract IrisLectureIngestionSubSettings getIrisLectureIngestionSettings(); public abstract void setIrisLectureIngestionSettings(IrisLectureIngestionSubSettings irisLectureIngestionSettings); diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSubSettings.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSubSettings.java index c9fc576311db..86e77fc9c034 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSubSettings.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSubSettings.java @@ -40,6 +40,7 @@ @JsonSubTypes({ @JsonSubTypes.Type(value = IrisChatSubSettings.class, name = "chat"), @JsonSubTypes.Type(value = IrisTextExerciseChatSubSettings.class, name = "text-exercise-chat"), + @JsonSubTypes.Type(value = IrisCourseChatSubSettings.class, name = "course-chat"), @JsonSubTypes.Type(value = IrisLectureIngestionSubSettings.class, name = "lecture-ingestion"), @JsonSubTypes.Type(value = IrisCompetencyGenerationSubSettings.class, name = "competency-generation") }) diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSubSettingsType.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSubSettingsType.java index dafdd1edcfb9..fe3561f12c2a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSubSettingsType.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSubSettingsType.java @@ -1,6 +1,6 @@ package de.tum.cit.aet.artemis.iris.domain.settings; public enum IrisSubSettingsType { - CHAT, // TODO: Split into PROGRAMMING_EXERCISE_CHAT and COURSE_CHAT - TEXT_EXERCISE_CHAT, COMPETENCY_GENERATION, LECTURE_INGESTION + CHAT, // TODO: Rename to PROGRAMMING_EXERCISE_CHAT + TEXT_EXERCISE_CHAT, COURSE_CHAT, COMPETENCY_GENERATION, LECTURE_INGESTION } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedCourseChatSubSettingsDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedCourseChatSubSettingsDTO.java new file mode 100644 index 000000000000..3c1cf365763d --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedCourseChatSubSettingsDTO.java @@ -0,0 +1,13 @@ +package de.tum.cit.aet.artemis.iris.dto; + +import java.util.SortedSet; + +import jakarta.annotation.Nullable; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record IrisCombinedCourseChatSubSettingsDTO(boolean enabled, Integer rateLimit, Integer rateLimitTimeframeHours, @Nullable SortedSet allowedVariants, + @Nullable String selectedVariant) { + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedSettingsDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedSettingsDTO.java index b05645603dbe..294f2e836140 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedSettingsDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedSettingsDTO.java @@ -7,6 +7,7 @@ public record IrisCombinedSettingsDTO( IrisCombinedChatSubSettingsDTO irisChatSettings, IrisCombinedTextExerciseChatSubSettingsDTO irisTextExerciseChatSettings, + IrisCombinedCourseChatSubSettingsDTO irisCourseChatSettings, IrisCombinedLectureIngestionSubSettingsDTO irisLectureIngestionSettings, IrisCombinedCompetencyGenerationSubSettingsDTO irisCompetencyGenerationSettings ) {} 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 d2743c2e71a5..7e6693991430 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 @@ -90,7 +90,7 @@ public void checkHasAccessTo(User user, IrisCourseChatSession session) { */ @Override public void checkIsFeatureActivatedFor(IrisCourseChatSession session) { - irisSettingsService.isEnabledForElseThrow(IrisSubSettingsType.CHAT, session.getCourse()); + irisSettingsService.isEnabledForElseThrow(IrisSubSettingsType.COURSE_CHAT, session.getCourse()); } @Override @@ -134,7 +134,7 @@ protected void setLLMTokenUsageParameters(LLMTokenUsageService.LLMTokenUsageBuil */ public void onJudgementOfLearningSet(CompetencyJol competencyJol) { var course = competencyJol.getCompetency().getCourse(); - if (!irisSettingsService.isEnabledFor(IrisSubSettingsType.CHAT, course)) { + if (!irisSettingsService.isEnabledFor(IrisSubSettingsType.COURSE_CHAT, course)) { return; } var user = competencyJol.getUser(); @@ -154,7 +154,7 @@ public void onJudgementOfLearningSet(CompetencyJol competencyJol) { */ public IrisCourseChatSession getCurrentSessionOrCreateIfNotExists(Course course, User user, boolean sendInitialMessageIfCreated) { user.hasAcceptedIrisElseThrow(); - irisSettingsService.isEnabledForElseThrow(IrisSubSettingsType.CHAT, course); + irisSettingsService.isEnabledForElseThrow(IrisSubSettingsType.COURSE_CHAT, course); return getCurrentSessionOrCreateIfNotExistsInternal(course, user, sendInitialMessageIfCreated); } @@ -184,7 +184,7 @@ private IrisCourseChatSession getCurrentSessionOrCreateIfNotExistsInternal(Cours */ public IrisCourseChatSession createSession(Course course, User user, boolean sendInitialMessage) { user.hasAcceptedIrisElseThrow(); - irisSettingsService.isEnabledForElseThrow(IrisSubSettingsType.CHAT, course); + irisSettingsService.isEnabledForElseThrow(IrisSubSettingsType.COURSE_CHAT, course); return createSessionInternal(course, user, sendInitialMessage); } 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 6047631fb5bf..d286def04e19 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 @@ -32,6 +32,7 @@ import de.tum.cit.aet.artemis.exercise.domain.Exercise; import de.tum.cit.aet.artemis.iris.domain.settings.IrisChatSubSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisCompetencyGenerationSubSettings; +import de.tum.cit.aet.artemis.iris.domain.settings.IrisCourseChatSubSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisCourseSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisExerciseSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisGlobalSettings; @@ -107,6 +108,7 @@ private void createInitialGlobalSettings() { initializeIrisChatSettings(settings); initializeIrisTextExerciseChatSettings(settings); + initializeIrisCourseChatSettings(settings); initializeIrisLectureIngestionSettings(settings); initializeIrisCompetencyGenerationSettings(settings); @@ -135,6 +137,12 @@ private void initializeIrisTextExerciseChatSettings(IrisGlobalSettings settings) settings.setIrisTextExerciseChatSettings(irisChatSettings); } + private void initializeIrisCourseChatSettings(IrisGlobalSettings settings) { + var irisChatSettings = settings.getIrisCourseChatSettings(); + irisChatSettings = initializeSettings(irisChatSettings, IrisCourseChatSubSettings::new); + settings.setIrisCourseChatSettings(irisChatSettings); + } + private void initializeIrisLectureIngestionSettings(IrisGlobalSettings settings) { var irisLectureIngestionSettings = settings.getIrisLectureIngestionSettings(); irisLectureIngestionSettings = initializeSettings(irisLectureIngestionSettings, IrisLectureIngestionSubSettings::new); @@ -207,18 +215,15 @@ private T updateIrisSettings(long existingSettingsId, T var existingSettings = irisSettingsRepository.findByIdElseThrow(existingSettingsId); - if (existingSettings instanceof IrisGlobalSettings globalSettings && settingsUpdate instanceof IrisGlobalSettings globalSettingsUpdate) { - return (T) updateGlobalSettings(globalSettings, globalSettingsUpdate); - } - else if (existingSettings instanceof IrisCourseSettings courseSettings && settingsUpdate instanceof IrisCourseSettings courseSettingsUpdate) { - return (T) updateCourseSettings(courseSettings, courseSettingsUpdate); - } - else if (existingSettings instanceof IrisExerciseSettings exerciseSettings && settingsUpdate instanceof IrisExerciseSettings exerciseSettingsUpdate) { - return (T) updateExerciseSettings(exerciseSettings, exerciseSettingsUpdate); - } - else { - throw new BadRequestAlertException("Unknown Iris settings type", "IrisSettings", "unknownType"); - } + return switch (existingSettings) { + case IrisGlobalSettings globalSettings when settingsUpdate instanceof IrisGlobalSettings globalSettingsUpdate -> + (T) updateGlobalSettings(globalSettings, globalSettingsUpdate); + case IrisCourseSettings courseSettings when settingsUpdate instanceof IrisCourseSettings courseSettingsUpdate -> + (T) updateCourseSettings(courseSettings, courseSettingsUpdate); + case IrisExerciseSettings exerciseSettings when settingsUpdate instanceof IrisExerciseSettings exerciseSettingsUpdate -> + (T) updateExerciseSettings(exerciseSettings, exerciseSettingsUpdate); + case null, default -> throw new BadRequestAlertException("Unknown Iris settings type", "IrisSettings", "unknownType"); + }; } /** @@ -230,29 +235,35 @@ else if (existingSettings instanceof IrisExerciseSettings exerciseSettings && se */ private IrisGlobalSettings updateGlobalSettings(IrisGlobalSettings existingSettings, IrisGlobalSettings settingsUpdate) { // @formatter:off - existingSettings.setIrisLectureIngestionSettings(irisSubSettingsService.update( - existingSettings.getIrisLectureIngestionSettings(), - settingsUpdate.getIrisLectureIngestionSettings(), - null, - GLOBAL + existingSettings.setIrisChatSettings(irisSubSettingsService.update( + existingSettings.getIrisChatSettings(), + settingsUpdate.getIrisChatSettings(), + null, + GLOBAL )); existingSettings.setIrisTextExerciseChatSettings(irisSubSettingsService.update( - existingSettings.getIrisTextExerciseChatSettings(), - settingsUpdate.getIrisTextExerciseChatSettings(), - null, - GLOBAL + existingSettings.getIrisTextExerciseChatSettings(), + settingsUpdate.getIrisTextExerciseChatSettings(), + null, + GLOBAL )); - existingSettings.setIrisChatSettings(irisSubSettingsService.update( - existingSettings.getIrisChatSettings(), - settingsUpdate.getIrisChatSettings(), - null, - GLOBAL + existingSettings.setIrisCourseChatSettings(irisSubSettingsService.update( + existingSettings.getIrisCourseChatSettings(), + settingsUpdate.getIrisCourseChatSettings(), + null, + GLOBAL + )); + existingSettings.setIrisLectureIngestionSettings(irisSubSettingsService.update( + existingSettings.getIrisLectureIngestionSettings(), + settingsUpdate.getIrisLectureIngestionSettings(), + null, + GLOBAL )); existingSettings.setIrisCompetencyGenerationSettings(irisSubSettingsService.update( - existingSettings.getIrisCompetencyGenerationSettings(), - settingsUpdate.getIrisCompetencyGenerationSettings(), - null, - GLOBAL + existingSettings.getIrisCompetencyGenerationSettings(), + settingsUpdate.getIrisCompetencyGenerationSettings(), + null, + GLOBAL )); // @formatter:on @@ -275,28 +286,34 @@ private IrisCourseSettings updateCourseSettings(IrisCourseSettings existingSetti var parentSettings = getCombinedIrisGlobalSettings(); // @formatter:off existingSettings.setIrisChatSettings(irisSubSettingsService.update( - existingSettings.getIrisChatSettings(), - settingsUpdate.getIrisChatSettings(), - parentSettings.irisChatSettings(), - COURSE + existingSettings.getIrisChatSettings(), + settingsUpdate.getIrisChatSettings(), + parentSettings.irisChatSettings(), + COURSE )); existingSettings.setIrisTextExerciseChatSettings(irisSubSettingsService.update( - existingSettings.getIrisTextExerciseChatSettings(), - settingsUpdate.getIrisTextExerciseChatSettings(), - parentSettings.irisTextExerciseChatSettings(), - COURSE + existingSettings.getIrisTextExerciseChatSettings(), + settingsUpdate.getIrisTextExerciseChatSettings(), + parentSettings.irisTextExerciseChatSettings(), + COURSE + )); + existingSettings.setIrisCourseChatSettings(irisSubSettingsService.update( + existingSettings.getIrisCourseChatSettings(), + settingsUpdate.getIrisCourseChatSettings(), + parentSettings.irisCourseChatSettings(), + COURSE )); existingSettings.setIrisLectureIngestionSettings(irisSubSettingsService.update( - existingSettings.getIrisLectureIngestionSettings(), - settingsUpdate.getIrisLectureIngestionSettings(), - parentSettings.irisLectureIngestionSettings(), - COURSE + existingSettings.getIrisLectureIngestionSettings(), + settingsUpdate.getIrisLectureIngestionSettings(), + parentSettings.irisLectureIngestionSettings(), + COURSE )); existingSettings.setIrisCompetencyGenerationSettings(irisSubSettingsService.update( - existingSettings.getIrisCompetencyGenerationSettings(), - settingsUpdate.getIrisCompetencyGenerationSettings(), - parentSettings.irisCompetencyGenerationSettings(), - COURSE + existingSettings.getIrisCompetencyGenerationSettings(), + settingsUpdate.getIrisCompetencyGenerationSettings(), + parentSettings.irisCompetencyGenerationSettings(), + COURSE )); // @formatter:on @@ -430,16 +447,16 @@ private IrisExerciseSettings updateExerciseSettings(IrisExerciseSettings existin var parentSettings = getCombinedIrisSettingsFor(existingSettings.getExercise().getCourseViaExerciseGroupOrCourseMember(), false); // @formatter:off existingSettings.setIrisChatSettings(irisSubSettingsService.update( - existingSettings.getIrisChatSettings(), - settingsUpdate.getIrisChatSettings(), - parentSettings.irisChatSettings(), - EXERCISE + existingSettings.getIrisChatSettings(), + settingsUpdate.getIrisChatSettings(), + parentSettings.irisChatSettings(), + EXERCISE )); existingSettings.setIrisTextExerciseChatSettings(irisSubSettingsService.update( - existingSettings.getIrisTextExerciseChatSettings(), - settingsUpdate.getIrisTextExerciseChatSettings(), - parentSettings.irisTextExerciseChatSettings(), - EXERCISE + existingSettings.getIrisTextExerciseChatSettings(), + settingsUpdate.getIrisTextExerciseChatSettings(), + parentSettings.irisTextExerciseChatSettings(), + EXERCISE )); // @formatter:on return irisSettingsRepository.save(existingSettings); @@ -507,10 +524,11 @@ public IrisCombinedSettingsDTO getCombinedIrisGlobalSettings() { // @formatter:off return new IrisCombinedSettingsDTO( - irisSubSettingsService.combineChatSettings(settingsList, false), - irisSubSettingsService.combineTextExerciseChatSettings(settingsList, false), - irisSubSettingsService.combineLectureIngestionSubSettings(settingsList, false), - irisSubSettingsService.combineCompetencyGenerationSettings(settingsList, false) + irisSubSettingsService.combineChatSettings(settingsList, false), + irisSubSettingsService.combineTextExerciseChatSettings(settingsList, false), + irisSubSettingsService.combineCourseChatSettings(settingsList, false), + irisSubSettingsService.combineLectureIngestionSubSettings(settingsList, false), + irisSubSettingsService.combineCompetencyGenerationSettings(settingsList, false) ); // @formatter:on } @@ -532,10 +550,11 @@ public IrisCombinedSettingsDTO getCombinedIrisSettingsFor(Course course, boolean // @formatter:off return new IrisCombinedSettingsDTO( - irisSubSettingsService.combineChatSettings(settingsList, minimal), - irisSubSettingsService.combineTextExerciseChatSettings(settingsList, minimal), - irisSubSettingsService.combineLectureIngestionSubSettings(settingsList, minimal), - irisSubSettingsService.combineCompetencyGenerationSettings(settingsList, minimal) + irisSubSettingsService.combineChatSettings(settingsList, minimal), + irisSubSettingsService.combineTextExerciseChatSettings(settingsList, minimal), + irisSubSettingsService.combineCourseChatSettings(settingsList, minimal), + irisSubSettingsService.combineLectureIngestionSubSettings(settingsList, minimal), + irisSubSettingsService.combineCompetencyGenerationSettings(settingsList, minimal) ); // @formatter:on } @@ -558,10 +577,11 @@ public IrisCombinedSettingsDTO getCombinedIrisSettingsFor(Exercise exercise, boo // @formatter:off return new IrisCombinedSettingsDTO( - irisSubSettingsService.combineChatSettings(settingsList, minimal), - irisSubSettingsService.combineTextExerciseChatSettings(settingsList, minimal), - irisSubSettingsService.combineLectureIngestionSubSettings(settingsList, minimal), - irisSubSettingsService.combineCompetencyGenerationSettings(settingsList, minimal) + irisSubSettingsService.combineChatSettings(settingsList, minimal), + irisSubSettingsService.combineTextExerciseChatSettings(settingsList, minimal), + irisSubSettingsService.combineCourseChatSettings(settingsList, minimal), + irisSubSettingsService.combineLectureIngestionSubSettings(settingsList, minimal), + irisSubSettingsService.combineCompetencyGenerationSettings(settingsList, minimal) ); // @formatter:on } @@ -587,10 +607,11 @@ public boolean shouldShowMinimalSettings(Exercise exercise, User user) { public IrisCourseSettings getDefaultSettingsFor(Course course) { var settings = new IrisCourseSettings(); settings.setCourse(course); - settings.setIrisLectureIngestionSettings(new IrisLectureIngestionSubSettings()); settings.setIrisChatSettings(new IrisChatSubSettings()); - settings.setIrisCompetencyGenerationSettings(new IrisCompetencyGenerationSubSettings()); settings.setIrisTextExerciseChatSettings(new IrisTextExerciseChatSubSettings()); + settings.setIrisCourseChatSettings(new IrisCourseChatSubSettings()); + settings.setIrisLectureIngestionSettings(new IrisLectureIngestionSubSettings()); + settings.setIrisCompetencyGenerationSettings(new IrisCompetencyGenerationSubSettings()); return settings; } @@ -664,6 +685,7 @@ private boolean isFeatureEnabledInSettings(IrisCombinedSettingsDTO settings, Iri return switch (type) { case CHAT -> settings.irisChatSettings().enabled(); case TEXT_EXERCISE_CHAT -> settings.irisTextExerciseChatSettings().enabled(); + case COURSE_CHAT -> settings.irisCourseChatSettings().enabled(); case COMPETENCY_GENERATION -> settings.irisCompetencyGenerationSettings().enabled(); case LECTURE_INGESTION -> settings.irisLectureIngestionSettings().enabled(); }; 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 2c284b6ea1f8..c6c17601e5af 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 @@ -17,6 +17,7 @@ import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; import de.tum.cit.aet.artemis.iris.domain.settings.IrisChatSubSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisCompetencyGenerationSubSettings; +import de.tum.cit.aet.artemis.iris.domain.settings.IrisCourseChatSubSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisCourseSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisExerciseSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisLectureIngestionSubSettings; @@ -26,6 +27,7 @@ import de.tum.cit.aet.artemis.iris.domain.settings.IrisTextExerciseChatSubSettings; import de.tum.cit.aet.artemis.iris.dto.IrisCombinedChatSubSettingsDTO; import de.tum.cit.aet.artemis.iris.dto.IrisCombinedCompetencyGenerationSubSettingsDTO; +import de.tum.cit.aet.artemis.iris.dto.IrisCombinedCourseChatSubSettingsDTO; import de.tum.cit.aet.artemis.iris.dto.IrisCombinedLectureIngestionSubSettingsDTO; import de.tum.cit.aet.artemis.iris.dto.IrisCombinedTextExerciseChatSubSettingsDTO; @@ -123,6 +125,37 @@ public IrisTextExerciseChatSubSettings update(IrisTextExerciseChatSubSettings cu return currentSettings; } + /** + * Updates a course chat sub settings object. + * + * @param currentSettings Current chat sub settings. + * @param newSettings Updated chat sub settings. + * @param parentSettings Parent chat sub settings. + * @param settingsType Type of the settings the sub settings belong to. + * @return Updated chat sub settings. + */ + public IrisCourseChatSubSettings update(IrisCourseChatSubSettings currentSettings, IrisCourseChatSubSettings newSettings, IrisCombinedCourseChatSubSettingsDTO parentSettings, + IrisSettingsType settingsType) { + if (newSettings == null) { + if (parentSettings == null) { + throw new IllegalArgumentException("Cannot delete the course chat settings"); + } + return null; + } + if (currentSettings == null) { + currentSettings = new IrisCourseChatSubSettings(); + } + if (authCheckService.isAdmin()) { + currentSettings.setEnabled(newSettings.isEnabled()); + currentSettings.setRateLimit(newSettings.getRateLimit()); + currentSettings.setRateLimitTimeframeHours(newSettings.getRateLimitTimeframeHours()); + } + currentSettings.setAllowedVariants(selectAllowedVariants(currentSettings.getAllowedVariants(), newSettings.getAllowedVariants())); + currentSettings.setSelectedVariant(validateSelectedVariant(currentSettings.getSelectedVariant(), newSettings.getSelectedVariant(), currentSettings.getAllowedVariants(), + parentSettings != null ? parentSettings.allowedVariants() : null)); + return currentSettings; + } + /** * Updates a Lecture Ingestion sub settings object. * If the new settings are null, the current settings will be deleted (except if the parent settings are null == if the settings are global). @@ -224,6 +257,24 @@ private String validateSelectedVariant(String selectedVariant, String newSelecte return selectedVariant; } + /** + * Combines the chat settings of multiple {@link IrisSettings} objects. + * If minimal is true, the returned object will only contain the enabled and rateLimit fields. + * The minimal version can safely be sent to students. + * + * @param settingsList List of {@link IrisSettings} objects to combine. + * @param minimal Whether to return a minimal version of the combined settings. + * @return Combined chat settings. + */ + public IrisCombinedChatSubSettingsDTO combineChatSettings(ArrayList settingsList, boolean minimal) { + var enabled = getCombinedEnabled(settingsList, IrisSettings::getIrisChatSettings); + var rateLimit = getCombinedRateLimit(settingsList); + var allowedVariants = !minimal ? getCombinedAllowedVariants(settingsList, IrisSettings::getIrisChatSettings) : null; + var selectedVariant = !minimal ? getCombinedSelectedVariant(settingsList, IrisSettings::getIrisChatSettings) : null; + var enabledForCategories = !minimal ? getCombinedEnabledForCategories(settingsList, IrisSettings::getIrisChatSettings) : null; + return new IrisCombinedChatSubSettingsDTO(enabled, rateLimit, null, allowedVariants, selectedVariant, enabledForCategories); + } + /** * Combines the chat settings of multiple {@link IrisSettings} objects. * If minimal is true, the returned object will only contain the enabled and rateLimit fields. @@ -251,13 +302,12 @@ public IrisCombinedTextExerciseChatSubSettingsDTO combineTextExerciseChatSetting * @param minimal Whether to return a minimal version of the combined settings. * @return Combined chat settings. */ - public IrisCombinedChatSubSettingsDTO combineChatSettings(ArrayList settingsList, boolean minimal) { - var enabled = getCombinedEnabled(settingsList, IrisSettings::getIrisChatSettings); + public IrisCombinedCourseChatSubSettingsDTO combineCourseChatSettings(ArrayList settingsList, boolean minimal) { + var enabled = getCombinedEnabled(settingsList, IrisSettings::getIrisCourseChatSettings); var rateLimit = getCombinedRateLimit(settingsList); var allowedVariants = !minimal ? getCombinedAllowedVariants(settingsList, IrisSettings::getIrisChatSettings) : null; var selectedVariant = !minimal ? getCombinedSelectedVariant(settingsList, IrisSettings::getIrisChatSettings) : null; - var enabledForCategories = !minimal ? getCombinedEnabledForCategories(settingsList, IrisSettings::getIrisChatSettings) : null; - return new IrisCombinedChatSubSettingsDTO(enabled, rateLimit, null, allowedVariants, selectedVariant, enabledForCategories); + return new IrisCombinedCourseChatSubSettingsDTO(enabled, rateLimit, null, allowedVariants, selectedVariant); } /** @@ -350,6 +400,14 @@ private String getCombinedSelectedVariant(List settingsList, Funct .filter(model -> model != null && !model.isBlank()).reduce((first, second) -> second).orElse(null); } + /** + * Combines the enabledForCategories field of multiple {@link IrisSettings} objects. + * Simply &&s all enabledForCategories fields together. + * + * @param settingsList List of {@link IrisSettings} objects to combine. + * @param subSettingsFunction Function to get the sub settings from an IrisSettings object. + * @return Combined enabledForCategories field. + */ private SortedSet getCombinedEnabledForCategories(List settingsList, Function subSettingsFunction) { return settingsList.stream().filter(Objects::nonNull).filter(settings -> settings instanceof IrisCourseSettings).map(subSettingsFunction).filter(Objects::nonNull) .map(IrisChatSubSettings::getEnabledForCategories).filter(Objects::nonNull).filter(models -> !models.isEmpty()).reduce((first, second) -> second) diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisCourseChatSessionResource.java b/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisCourseChatSessionResource.java index 13c7a1b5894d..583776c922c7 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisCourseChatSessionResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisCourseChatSessionResource.java @@ -91,7 +91,7 @@ public ResponseEntity getCurrentSessionOrCreateIfNotExist public ResponseEntity> getAllSessions(@PathVariable Long courseId) { var course = courseRepository.findByIdElseThrow(courseId); - irisSettingsService.isEnabledForElseThrow(IrisSubSettingsType.CHAT, course); + irisSettingsService.isEnabledForElseThrow(IrisSubSettingsType.COURSE_CHAT, course); var user = userRepository.getUserWithGroupsAndAuthorities(); user.hasAcceptedIrisElseThrow(); diff --git a/src/main/resources/config/liquibase/changelog/20241119191919_changelog.xml b/src/main/resources/config/liquibase/changelog/20241119191919_changelog.xml new file mode 100644 index 000000000000..4ad2d701458c --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20241119191919_changelog.xml @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml index 4f682ca9b8e0..a2f522a1674c 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -38,6 +38,7 @@ + diff --git a/src/main/webapp/app/course/manage/detail/course-detail.component.ts b/src/main/webapp/app/course/manage/detail/course-detail.component.ts index 515b1b37e291..d61213a17626 100644 --- a/src/main/webapp/app/course/manage/detail/course-detail.component.ts +++ b/src/main/webapp/app/course/manage/detail/course-detail.component.ts @@ -92,6 +92,7 @@ export class CourseDetailComponent implements OnInit, OnDestroy { this.irisEnabled = profileInfo?.activeProfiles.includes(PROFILE_IRIS); if (this.irisEnabled) { const irisSettings = await firstValueFrom(this.irisSettingsService.getGlobalSettings()); + // TODO: Outdated, as we now have a bunch more sub settings this.irisChatEnabled = irisSettings?.irisChatSettings?.enabled ?? false; } this.route.data.subscribe(({ course }) => { diff --git a/src/main/webapp/app/entities/iris/settings/iris-settings.model.ts b/src/main/webapp/app/entities/iris/settings/iris-settings.model.ts index 2bc612cde7b0..270a2d5132e9 100644 --- a/src/main/webapp/app/entities/iris/settings/iris-settings.model.ts +++ b/src/main/webapp/app/entities/iris/settings/iris-settings.model.ts @@ -2,6 +2,7 @@ import { BaseEntity } from 'app/shared/model/base-entity'; import { IrisChatSubSettings, IrisCompetencyGenerationSubSettings, + IrisCourseChatSubSettings, IrisLectureIngestionSubSettings, IrisTextExerciseChatSubSettings, } from 'app/entities/iris/settings/iris-sub-settings.model'; @@ -17,6 +18,7 @@ export abstract class IrisSettings implements BaseEntity { type: IrisSettingsType; irisChatSettings?: IrisChatSubSettings; irisTextExerciseChatSettings?: IrisTextExerciseChatSubSettings; + irisCourseChatSettings?: IrisCourseChatSubSettings; irisLectureIngestionSettings?: IrisLectureIngestionSubSettings; irisCompetencyGenerationSettings?: IrisCompetencyGenerationSubSettings; } @@ -26,6 +28,7 @@ export class IrisGlobalSettings implements IrisSettings { type = IrisSettingsType.GLOBAL; irisChatSettings?: IrisChatSubSettings; irisTextExerciseChatSettings?: IrisTextExerciseChatSubSettings; + irisCourseChatSettings?: IrisCourseChatSubSettings; irisLectureIngestionSettings?: IrisLectureIngestionSubSettings; irisCompetencyGenerationSettings?: IrisCompetencyGenerationSubSettings; } @@ -36,6 +39,7 @@ export class IrisCourseSettings implements IrisSettings { courseId?: number; irisChatSettings?: IrisChatSubSettings; irisTextExerciseChatSettings?: IrisTextExerciseChatSubSettings; + irisCourseChatSettings?: IrisCourseChatSubSettings; irisLectureIngestionSettings?: IrisLectureIngestionSubSettings; irisCompetencyGenerationSettings?: IrisCompetencyGenerationSubSettings; } 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 2225f8d6bbe1..6b99edff0804 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 @@ -1,10 +1,11 @@ import { BaseEntity } from 'app/shared/model/base-entity'; export enum IrisSubSettingsType { + CHAT = 'chat', // TODO: Rename to PROGRAMMING_EXERCISE_CHAT TEXT_EXERCISE_CHAT = 'text-exercise-chat', - CHAT = 'chat', // TODO: Split into PROGRAMMING_EXERCISE_CHAT and COURSE_CHAT - COMPETENCY_GENERATION = 'competency-generation', + COURSE_CHAT = 'course-chat', LECTURE_INGESTION = 'lecture-ingestion', + COMPETENCY_GENERATION = 'competency-generation', } export abstract class IrisSubSettings implements BaseEntity { @@ -28,6 +29,12 @@ export class IrisTextExerciseChatSubSettings extends IrisSubSettings { rateLimitTimeframeHours?: number; } +export class IrisCourseChatSubSettings extends IrisSubSettings { + type = IrisSubSettingsType.COURSE_CHAT; + rateLimit?: number; + rateLimitTimeframeHours?: number; +} + export class IrisLectureIngestionSubSettings extends IrisSubSettings { type = IrisSubSettingsType.LECTURE_INGESTION; autoIngestOnLectureAttachmentUpload: boolean; 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 0d4f1898653b..78e23b9dbaa6 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 @@ -75,7 +75,7 @@ export class IrisCommonSubSettingsUpdateComponent implements OnInit, OnChanges { } ngOnChanges(changes: SimpleChanges): void { - if (changes.availableVariants) { + if (!this.inheritAllowedVariants && changes.availableVariants) { this.allowedVariants = this.getAllowedVariants(); } if (changes.subSettings) { diff --git a/src/main/webapp/app/iris/settings/iris-settings-update/iris-settings-update.component.html b/src/main/webapp/app/iris/settings/iris-settings-update/iris-settings-update.component.html index a2b7109c01c2..f97278bc7a90 100644 --- a/src/main/webapp/app/iris/settings/iris-settings-update/iris-settings-update.component.html +++ b/src/main/webapp/app/iris/settings/iris-settings-update/iris-settings-update.component.html @@ -21,7 +21,9 @@

(onChanges)="isDirty = true" /> +
+

@if (settingsType !== EXERCISE) { +
+ +

+
+ +
+ +
+
-

+
-

{ if (profileInfo?.activeProfiles.includes(PROFILE_IRIS)) { this.irisSettingsService.getCombinedCourseSettings(this.courseId).subscribe((settings) => { - this.irisEnabled = !!settings?.irisChatSettings?.enabled; + this.irisEnabled = !!settings?.irisCourseChatSettings?.enabled; }); } }); diff --git a/src/main/webapp/i18n/de/iris.json b/src/main/webapp/i18n/de/iris.json index 1f302a833001..6d1200737895 100644 --- a/src/main/webapp/i18n/de/iris.json +++ b/src/main/webapp/i18n/de/iris.json @@ -26,6 +26,7 @@ "subSettings": { "chatSettings": "Chat Einstellungen", "textExerciseChatSettings": "Textaufgaben Chat Einstellungen", + "courseChatSettings": "Kurs Chat Einstellungen", "lectureIngestionSettings": { "title": "Vorlesungen Erfassung Einstellungen", "autoIngestOnAttachmentUpload": "Vorlesungen automatisch an Pyris senden" diff --git a/src/main/webapp/i18n/en/iris.json b/src/main/webapp/i18n/en/iris.json index 65f153b89c54..f0e4072441be 100644 --- a/src/main/webapp/i18n/en/iris.json +++ b/src/main/webapp/i18n/en/iris.json @@ -26,6 +26,7 @@ "subSettings": { "chatSettings": "Chat Settings", "textExerciseChatSettings": "Text Exercise Chat Settings", + "courseChatSettings": "Course Chat Settings", "lectureIngestionSettings": { "title": "Lecture Ingestion Settings", "autoIngestOnAttachmentUpload": "Send Lectures To Pyris Automatically" 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 c887cffce4d9..7820ffe46931 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 @@ -59,9 +59,10 @@ void tearDown() throws Exception { protected void activateIrisGlobally() { var globalSettings = irisSettingsService.getGlobalSettings(); activateSubSettings(globalSettings.getIrisChatSettings()); + activateSubSettings(globalSettings.getIrisTextExerciseChatSettings()); + activateSubSettings(globalSettings.getIrisCourseChatSettings()); activateSubSettings(globalSettings.getIrisLectureIngestionSettings()); activateSubSettings(globalSettings.getIrisCompetencyGenerationSettings()); - activateSubSettings(globalSettings.getIrisTextExerciseChatSettings()); irisSettingsRepository.save(globalSettings); } @@ -80,13 +81,11 @@ protected void activateIrisFor(Course course) { var courseSettings = irisSettingsService.getDefaultSettingsFor(course); activateSubSettings(courseSettings.getIrisChatSettings()); - + activateSubSettings(courseSettings.getIrisTextExerciseChatSettings()); + activateSubSettings(courseSettings.getIrisCourseChatSettings()); activateSubSettings(courseSettings.getIrisCompetencyGenerationSettings()); - activateSubSettings(courseSettings.getIrisLectureIngestionSettings()); - activateSubSettings(courseSettings.getIrisTextExerciseChatSettings()); - irisSettingsRepository.save(courseSettings); } 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 8e877d1e50e2..7912b732a639 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 @@ -26,10 +26,12 @@ import de.tum.cit.aet.artemis.iris.AbstractIrisIntegrationTest; import de.tum.cit.aet.artemis.iris.domain.settings.IrisChatSubSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisCompetencyGenerationSubSettings; +import de.tum.cit.aet.artemis.iris.domain.settings.IrisCourseChatSubSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisCourseSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisExerciseSettings; 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.dto.IrisCombinedSettingsDTO; import de.tum.cit.aet.artemis.iris.repository.IrisSettingsRepository; import de.tum.cit.aet.artemis.iris.repository.IrisSubSettingsRepository; @@ -158,6 +160,8 @@ void updateCourseSettings1() throws Exception { var loadedSettings1 = request.get("/api/courses/" + course.getId() + "/raw-iris-settings", HttpStatus.OK, IrisSettings.class); loadedSettings1.getIrisChatSettings().setEnabled(false); + loadedSettings1.getIrisTextExerciseChatSettings().setEnabled(false); + loadedSettings1.getIrisCourseChatSettings().setEnabled(false); loadedSettings1.getIrisCompetencyGenerationSettings().setEnabled(false); loadedSettings1.getIrisLectureIngestionSettings().setEnabled(false); @@ -167,9 +171,11 @@ void updateCourseSettings1() throws Exception { assertThat(updatedSettings).isNotNull().isEqualTo(loadedSettings2); // Ids of settings should not have changed assertThat(updatedSettings.getId()).isEqualTo(loadedSettings1.getId()); - assertThat(updatedSettings.getIrisLectureIngestionSettings().getId()).isEqualTo(loadedSettings1.getIrisLectureIngestionSettings().getId()); assertThat(updatedSettings.getIrisChatSettings().getId()).isEqualTo(loadedSettings1.getIrisChatSettings().getId()); + assertThat(updatedSettings.getIrisTextExerciseChatSettings().getId()).isEqualTo(loadedSettings1.getIrisTextExerciseChatSettings().getId()); + assertThat(updatedSettings.getIrisCourseChatSettings().getId()).isEqualTo(loadedSettings1.getIrisCourseChatSettings().getId()); assertThat(updatedSettings.getIrisCompetencyGenerationSettings().getId()).isEqualTo(loadedSettings1.getIrisCompetencyGenerationSettings().getId()); + assertThat(updatedSettings.getIrisLectureIngestionSettings().getId()).isEqualTo(loadedSettings1.getIrisLectureIngestionSettings().getId()); } @Test @@ -182,11 +188,15 @@ void updateCourseSettings2() throws Exception { var loadedSettings1 = request.get("/api/courses/" + course.getId() + "/raw-iris-settings", HttpStatus.OK, IrisSettings.class); var chatSubSettingsId = loadedSettings1.getIrisChatSettings().getId(); + var textExerciseChatSubSettingsId = loadedSettings1.getIrisTextExerciseChatSettings().getId(); + var courseChatSubSettingsId = loadedSettings1.getIrisCourseChatSettings().getId(); var competencyGenerationSubSettingsId = loadedSettings1.getIrisCompetencyGenerationSettings().getId(); var lectureIngestionSubSettingsId = loadedSettings1.getIrisLectureIngestionSettings().getId(); - loadedSettings1.setIrisLectureIngestionSettings(null); loadedSettings1.setIrisChatSettings(null); + loadedSettings1.setIrisTextExerciseChatSettings(null); + loadedSettings1.setIrisCourseChatSettings(null); loadedSettings1.setIrisCompetencyGenerationSettings(null); + loadedSettings1.setIrisLectureIngestionSettings(null); 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); @@ -194,9 +204,11 @@ void updateCourseSettings2() throws Exception { assertThat(updatedSettings).isNotNull().usingRecursiveComparison().ignoringFields("course").isEqualTo(loadedSettings1); assertThat(updatedSettings).isNotNull().usingRecursiveComparison().ignoringFields("course").isEqualTo(loadedSettings2); // Original subsettings should not exist anymore - assertThat(irisSubSettingsRepository.findById(lectureIngestionSubSettingsId)).isEmpty(); assertThat(irisSubSettingsRepository.findById(chatSubSettingsId)).isEmpty(); + assertThat(irisSubSettingsRepository.findById(textExerciseChatSubSettingsId)).isEmpty(); + assertThat(irisSubSettingsRepository.findById(courseChatSubSettingsId)).isEmpty(); assertThat(irisSubSettingsRepository.findById(competencyGenerationSubSettingsId)).isEmpty(); + assertThat(irisSubSettingsRepository.findById(lectureIngestionSubSettingsId)).isEmpty(); } @Test @@ -211,19 +223,28 @@ void updateCourseSettings3() throws Exception { courseSettings.getIrisChatSettings().setEnabled(true); courseSettings.getIrisChatSettings().setSelectedVariant(null); + courseSettings.setIrisTextExerciseChatSettings(new IrisTextExerciseChatSubSettings()); + courseSettings.getIrisTextExerciseChatSettings().setEnabled(true); + courseSettings.getIrisTextExerciseChatSettings().setSelectedVariant(null); + + courseSettings.setIrisCourseChatSettings(new IrisCourseChatSubSettings()); + courseSettings.getIrisCourseChatSettings().setEnabled(true); + courseSettings.getIrisCourseChatSettings().setSelectedVariant(null); + courseSettings.setIrisCompetencyGenerationSettings(new IrisCompetencyGenerationSubSettings()); courseSettings.getIrisCompetencyGenerationSettings().setEnabled(true); courseSettings.getIrisCompetencyGenerationSettings().setSelectedVariant(null); courseSettings.setIrisLectureIngestionSettings(new IrisLectureIngestionSubSettings()); courseSettings.getIrisLectureIngestionSettings().setEnabled(true); + courseSettings.getIrisLectureIngestionSettings().setSelectedVariant(null); var updatedSettings = request.putWithResponseBody("/api/courses/" + course.getId() + "/raw-iris-settings", courseSettings, IrisSettings.class, HttpStatus.OK); var loadedSettings1 = request.get("/api/courses/" + course.getId() + "/raw-iris-settings", HttpStatus.OK, IrisSettings.class); assertThat(updatedSettings).usingRecursiveComparison().ignoringFields("course").isEqualTo(loadedSettings1); - assertThat(loadedSettings1).usingRecursiveComparison().ignoringFields("id", "course", "irisChatSettings.id", "irisChatSettings.template.id", - "irisLectureIngestionSettings.id", "irisCompetencyGenerationSettings.id", "irisCompetencyGenerationSettings.template.id").isEqualTo(courseSettings); + assertThat(loadedSettings1).usingRecursiveComparison().ignoringFields("id", "course", "irisChatSettings.id", "irisTextExerciseChatSettings.id", + "irisLectureIngestionSettings.id", "irisCompetencyGenerationSettings.id", "irisCourseChatSettings.id").isEqualTo(courseSettings); } /** 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 8e721fcc7653..6271fd91f52e 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 @@ -64,7 +64,7 @@ describe('IrisCourseSettingsUpdateComponent Component', () => { expect(getSettingsSpy).toHaveBeenCalledWith(1); expect(getParentSettingsSpy).toHaveBeenCalledOnce(); - expect(fixture.debugElement.queryAll(By.directive(IrisCommonSubSettingsUpdateComponent))).toHaveLength(4); + expect(fixture.debugElement.queryAll(By.directive(IrisCommonSubSettingsUpdateComponent))).toHaveLength(5); }); it('Can deactivate correctly', () => { @@ -87,11 +87,14 @@ describe('IrisCourseSettingsUpdateComponent Component', () => { expect(setSettingsSpy).toHaveBeenCalledWith(1, irisSettings); expect(comp.settingsUpdateComponent!.irisSettings).toEqual(irisSettingsSaved); }); + it('Fills the settings if they are empty', () => { fixture.detectChanges(); comp.settingsUpdateComponent!.irisSettings = mockEmptySettings(); comp.settingsUpdateComponent!.fillEmptyIrisSubSettings(); expect(comp.settingsUpdateComponent!.irisSettings.irisChatSettings).toBeTruthy(); + expect(comp.settingsUpdateComponent!.irisSettings.irisTextExerciseChatSettings).toBeTruthy(); + expect(comp.settingsUpdateComponent!.irisSettings.irisCourseChatSettings).toBeTruthy(); expect(comp.settingsUpdateComponent!.irisSettings.irisLectureIngestionSettings).toBeTruthy(); expect(comp.settingsUpdateComponent!.irisSettings.irisCompetencyGenerationSettings).toBeTruthy(); }); 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 0478f3ad2f7a..9c325fbea0d0 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 @@ -52,7 +52,7 @@ describe('IrisGlobalSettingsUpdateComponent Component', () => { expect(comp.settingsUpdateComponent).toBeTruthy(); expect(getSettingsSpy).toHaveBeenCalledOnce(); - expect(fixture.debugElement.queryAll(By.directive(IrisCommonSubSettingsUpdateComponent))).toHaveLength(4); + expect(fixture.debugElement.queryAll(By.directive(IrisCommonSubSettingsUpdateComponent))).toHaveLength(5); }); it('Can deactivate correctly', () => { 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 109fb0fdc04f..ef7c75f48a9e 100644 --- a/src/test/javascript/spec/component/iris/settings/mock-settings.ts +++ b/src/test/javascript/spec/component/iris/settings/mock-settings.ts @@ -2,6 +2,7 @@ import { IrisVariant } from 'app/entities/iris/settings/iris-variant'; import { IrisChatSubSettings, IrisCompetencyGenerationSubSettings, + IrisCourseChatSubSettings, IrisLectureIngestionSubSettings, IrisTextExerciseChatSubSettings, } from 'app/entities/iris/settings/iris-sub-settings.model'; @@ -14,6 +15,9 @@ export function mockSettings() { const mockTextExerciseChatSettings = new IrisTextExerciseChatSubSettings(); mockTextExerciseChatSettings.id = 13; mockTextExerciseChatSettings.enabled = true; + const mockCourseChatSettings = new IrisCourseChatSubSettings(); + mockCourseChatSettings.id = 3; + mockCourseChatSettings.enabled = true; const mockLectureIngestionSettings = new IrisLectureIngestionSubSettings(); mockLectureIngestionSettings.id = 7; mockLectureIngestionSettings.enabled = true; @@ -25,6 +29,7 @@ export function mockSettings() { irisSettings.id = 1; irisSettings.irisChatSettings = mockChatSettings; irisSettings.irisTextExerciseChatSettings = mockTextExerciseChatSettings; + irisSettings.irisCourseChatSettings = mockCourseChatSettings; irisSettings.irisCompetencyGenerationSettings = mockCompetencyGenerationSettings; irisSettings.irisLectureIngestionSettings = mockLectureIngestionSettings; return irisSettings; From b44dafa3b9c216f72a9971797385488719888a9d Mon Sep 17 00:00:00 2001 From: Patrick Bassner Date: Tue, 10 Dec 2024 14:45:08 +0100 Subject: [PATCH 2/3] Iris: Allow team repository access for Iris (#9975) --- .../IrisExerciseChatSessionService.java | 11 +- ...xerciseStudentParticipationRepository.java | 12 ++ .../connector/IrisRequestMockProvider.java | 33 ++++ .../exercise/util/ExerciseUtilService.java | 17 +- .../iris/IrisChatMessageIntegrationTest.java | 177 ++++++++++++------ 5 files changed, 191 insertions(+), 59 deletions(-) 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 a51f1730e98c..20aa684e534a 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 @@ -2,6 +2,7 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_IRIS; +import java.util.List; import java.util.Objects; import java.util.Optional; @@ -27,6 +28,7 @@ import de.tum.cit.aet.artemis.iris.service.settings.IrisSettingsService; import de.tum.cit.aet.artemis.iris.service.websocket.IrisChatWebsocketService; 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.repository.ProgrammingExerciseRepository; import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseStudentParticipationRepository; @@ -144,7 +146,14 @@ public void requestAndHandleResponse(IrisExerciseChatSession session) { } private Optional getLatestSubmissionIfExists(ProgrammingExercise exercise, User user) { - var participations = programmingExerciseStudentParticipationRepository.findAllWithSubmissionsByExerciseIdAndStudentLogin(exercise.getId(), user.getLogin()); + List participations; + if (exercise.isTeamMode()) { + participations = programmingExerciseStudentParticipationRepository.findAllWithSubmissionByExerciseIdAndStudentLoginInTeam(exercise.getId(), user.getLogin()); + } + else { + participations = programmingExerciseStudentParticipationRepository.findAllWithSubmissionsByExerciseIdAndStudentLogin(exercise.getId(), user.getLogin()); + } + if (participations.isEmpty()) { return Optional.empty(); } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseStudentParticipationRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseStudentParticipationRepository.java index c88024f0835b..a126934267ae 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseStudentParticipationRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseStudentParticipationRepository.java @@ -195,6 +195,18 @@ Page findRepositoryUrisByRecentDueDateOrRecentExamEndDate(@Param("earlie """) List findAllWithSubmissionsByExerciseIdAndStudentLogin(@Param("exerciseId") long exerciseId, @Param("username") String username); + @Query(""" + SELECT participation + FROM ProgrammingExerciseStudentParticipation participation + LEFT JOIN FETCH participation.team team + LEFT JOIN FETCH team.students student + LEFT JOIN FETCH participation.submissions + WHERE participation.exercise.id = :exerciseId + AND student.login = :username + ORDER BY participation.testRun ASC + """) + List findAllWithSubmissionByExerciseIdAndStudentLoginInTeam(@Param("exerciseId") long exerciseId, @Param("username") String username); + @EntityGraph(type = LOAD, attributePaths = "team.students") Optional findWithTeamStudentsById(long participationId); 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 4bee55d7481b..0d903d5a0e17 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 @@ -1,6 +1,7 @@ package de.tum.cit.aet.artemis.core.connector; import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_IRIS; +import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; import static org.springframework.test.web.client.response.MockRestResponseCreators.withRawStatus; @@ -100,6 +101,38 @@ public void mockProgrammingExerciseChatResponse(Consumer responseConsumer, long submissionId) { + // @formatter:off + mockServer + .expect(ExpectedCount.once(), requestTo(pipelinesApiURL + "/tutor-chat/default/run")) + .andExpect(method(HttpMethod.POST)) + .andExpect(request -> { + var mockRequest = (MockClientHttpRequest) request; + var jsonNode = mapper.readTree(mockRequest.getBodyAsString()); + + assertThat(jsonNode.has("submission")) + .withFailMessage("Request body must contain a 'submission' field") + .isTrue(); + assertThat(jsonNode.get("submission").isObject()) + .withFailMessage("The 'submission' field must be an object") + .isTrue(); + assertThat(jsonNode.get("submission").has("id")) + .withFailMessage("The 'submission' object must contain an 'id' field") + .isTrue(); + assertThat(jsonNode.get("submission").get("id").asLong()) + .withFailMessage("Submission ID in request (%d) does not match expected ID (%d)", + jsonNode.get("submission").get("id").asLong(), submissionId) + .isEqualTo(submissionId); + }) + .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); + }); + // @formatter:on + } + public void mockTextExerciseChatResponse(Consumer responseConsumer) { // @formatter:off mockServer diff --git a/src/test/java/de/tum/cit/aet/artemis/exercise/util/ExerciseUtilService.java b/src/test/java/de/tum/cit/aet/artemis/exercise/util/ExerciseUtilService.java index db92da355b2f..bae88a92d1e9 100644 --- a/src/test/java/de/tum/cit/aet/artemis/exercise/util/ExerciseUtilService.java +++ b/src/test/java/de/tum/cit/aet/artemis/exercise/util/ExerciseUtilService.java @@ -159,7 +159,7 @@ public Set addGradingInstructionsToExercise(Exercise exercise) } /** - * Accesses the first found exercise of a course with the passed type. The course stores exercises in a set, therefore any + * Accesses the first found non-team exercise of a course with the passed type. The course stores exercises in a set, therefore any * exercise with the corresponding type could be accessed. * * @param course The course which should be searched for the exercise. @@ -167,7 +167,20 @@ public Set addGradingInstructionsToExercise(Exercise exercise) * @return The first exercise which was found in the course and is of the expected type. */ public T getFirstExerciseWithType(Course course, Class clazz) { - var exercise = course.getExercises().stream().filter(ex -> ex.getClass().equals(clazz)).findFirst().orElseThrow(); + var exercise = course.getExercises().stream().filter(ex -> !ex.isTeamMode() && ex.getClass().equals(clazz)).findFirst().orElseThrow(); + return (T) exercise; + } + + /** + * Accesses the first found team exercise of a course with the passed type. The course stores exercises in a set, therefore any + * exercise with the corresponding type could be accessed. + * + * @param course The course which should be searched for the exercise. + * @param clazz The class (type) of the exercise to look for. + * @return The first exercise which was found in the course and is of the expected type. + */ + public T getFirstTeamExerciseWithType(Course course, Class clazz) { + var exercise = course.getExercises().stream().filter(ex -> ex.isTeamMode() && ex.getClass().equals(clazz)).findFirst().orElseThrow(); return (T) exercise; } diff --git a/src/test/java/de/tum/cit/aet/artemis/iris/IrisChatMessageIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/iris/IrisChatMessageIntegrationTest.java index 96c047ad7345..27dd4f480044 100644 --- a/src/test/java/de/tum/cit/aet/artemis/iris/IrisChatMessageIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/iris/IrisChatMessageIntegrationTest.java @@ -17,8 +17,10 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Stream; import org.eclipse.jgit.api.errors.GitAPIException; import org.junit.jupiter.api.BeforeEach; @@ -31,7 +33,12 @@ import org.springframework.util.LinkedMultiValueMap; import de.tum.cit.aet.artemis.core.domain.Course; +import de.tum.cit.aet.artemis.core.domain.User; +import de.tum.cit.aet.artemis.exercise.domain.ExerciseMode; +import de.tum.cit.aet.artemis.exercise.domain.Team; +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.repository.TeamRepository; import de.tum.cit.aet.artemis.iris.domain.message.IrisMessage; import de.tum.cit.aet.artemis.iris.domain.message.IrisMessageContent; import de.tum.cit.aet.artemis.iris.domain.message.IrisMessageSender; @@ -67,73 +74,131 @@ class IrisChatMessageIntegrationTest extends AbstractIrisIntegrationTest { @Autowired private IrisMessageRepository irisMessageRepository; + @Autowired + private TeamRepository teamRepository; + @Autowired private ParticipationUtilService participationUtilService; - private ProgrammingExercise exercise; + private ProgrammingExercise soloExercise; + + private ProgrammingExerciseStudentParticipation soloParticipation; + + private ProgrammingExercise teamExercise; + + private ProgrammingExerciseStudentParticipation teamParticipation; private AtomicBoolean pipelineDone; @BeforeEach void initTestCase() throws GitAPIException, IOException, URISyntaxException { - userUtilService.addUsers(TEST_PREFIX, 2, 0, 0, 0); + List users = userUtilService.addUsers(TEST_PREFIX, 3, 0, 0, 0); final Course course = programmingExerciseUtilService.addCourseWithOneProgrammingExercise(); - exercise = exerciseUtilService.getFirstExerciseWithType(course, ProgrammingExercise.class); - String projectKey = exercise.getProjectKey(); - exercise.setProjectType(ProjectType.PLAIN_GRADLE); - exercise.setTestRepositoryUri(localVCBaseUrl + "/git/" + projectKey + "/" + projectKey.toLowerCase() + "-tests.git"); - programmingExerciseBuildConfigRepository.save(exercise.getBuildConfig()); - 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. - ProgrammingExerciseStudentParticipation 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); + + soloExercise = exerciseUtilService.getFirstExerciseWithType(course, ProgrammingExercise.class); + teamExercise = programmingExerciseUtilService.addProgrammingExerciseToCourse(course); + teamExercise.setMode(ExerciseMode.TEAM); + programmingExerciseRepository.save(teamExercise); + + Team team = new Team(); + team.setName("Team 1"); + team.setShortName("team1"); + team.setExercise(teamExercise); + team.setStudents(Set.of(users.get(1), users.get(2))); + team.setOwner(users.get(1)); + final var savedTeam = teamRepository.save(team); + + Stream.of(soloExercise, teamExercise).forEach(exercise -> { + String projectKey = exercise.getProjectKey(); + exercise.setProjectType(ProjectType.PLAIN_GRADLE); + exercise.setTestRepositoryUri(localVCBaseUrl + "/git/" + projectKey + "/" + projectKey.toLowerCase() + "-tests.git"); + programmingExerciseBuildConfigRepository.save(exercise.getBuildConfig()); + 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 + (exercise.isTeamMode() ? "team1" : "student1"); + + // Add a participation + ProgrammingExerciseStudentParticipation studentParticipation; + if (exercise.isTeamMode()) { + studentParticipation = participationUtilService.addTeamParticipationForProgrammingExercise(exercise, savedTeam); + } + else { + studentParticipation = participationUtilService.addStudentParticipationForProgrammingExercise(exercise, TEST_PREFIX + "student1"); + } + + var submission = ParticipationFactory.generateProgrammingSubmission(true); + participationUtilService.addSubmission(studentParticipation, submission); + + studentParticipation.setRepositoryUri(String.format(localVCBaseUrl + "/git/%s/%s.git", projectKey, assignmentRepositorySlug)); + studentParticipation.setBranch(defaultBranch); + programmingExerciseStudentParticipationRepository.save(studentParticipation); + + if (exercise.isTeamMode()) { + teamParticipation = studentParticipation; + } + else { + soloParticipation = studentParticipation; + } + + // Prepare the repositories. + try { + localVCLocalCITestService.createAndConfigureLocalRepository(projectKey, templateRepositorySlug); + localVCLocalCITestService.createAndConfigureLocalRepository(projectKey, projectKey.toLowerCase() + "-tests"); + localVCLocalCITestService.createAndConfigureLocalRepository(projectKey, solutionRepositorySlug); + localVCLocalCITestService.createAndConfigureLocalRepository(projectKey, assignmentRepositorySlug); + } + catch (GitAPIException | IOException | URISyntaxException e) { + throw new RuntimeException(e); + } + + // Check that the repository folders were created in the file system for all base repositories. + localVCLocalCITestService.verifyRepositoryFoldersExist(exercise, localVCBasePath); + + activateIrisFor(exercise); + }); + pipelineDone = new AtomicBoolean(false); } @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") - void sendOneMessage() throws Exception { - var irisSession = irisExerciseChatSessionService.createChatSessionForProgrammingExercise(exercise, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); + void sendOneMessageNormalExercise() throws Exception { + sendOneMessage(soloExercise, "student1", soloParticipation.getSubmissions().iterator().next().getId()); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student2", roles = "USER") + void sendOneMessageTeamExercise() throws Exception { + sendOneMessage(teamExercise, "student2", teamParticipation.getSubmissions().iterator().next().getId()); + } + + private void sendOneMessage(ProgrammingExercise exercise, String studentLogin, long submissionId) throws Exception { + var irisSession = irisExerciseChatSessionService.createChatSessionForProgrammingExercise(exercise, userUtilService.getUserByLogin(TEST_PREFIX + studentLogin)); var messageToSend = createDefaultMockMessage(irisSession); messageToSend.setMessageDifferentiator(1453); - irisRequestMockProvider.mockProgrammingExerciseChatResponse(dto -> { + irisRequestMockProvider.mockProgrammingExerciseChatResponseExpectingSubmissionId(dto -> { assertThat(dto.settings().authenticationToken()).isNotNull(); assertThatNoException().isThrownBy(() -> sendStatus(dto.settings().authenticationToken(), "Hello World", dto.initialStages(), null)); pipelineDone.set(true); - }); + }, submissionId); request.postWithoutResponseBody("/api/iris/sessions/" + irisSession.getId() + "/messages", messageToSend, HttpStatus.CREATED); @@ -146,7 +211,7 @@ void sendOneMessage() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") void sendSuggestions() throws Exception { - var irisSession = irisExerciseChatSessionService.createChatSessionForProgrammingExercise(exercise, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); + var irisSession = irisExerciseChatSessionService.createChatSessionForProgrammingExercise(soloExercise, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); var messageToSend = createDefaultMockMessage(irisSession); messageToSend.setMessageDifferentiator(1454); @@ -171,8 +236,8 @@ void sendSuggestions() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") void sendOneMessageToWrongSession() throws Exception { - irisExerciseChatSessionService.createChatSessionForProgrammingExercise(exercise, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); - var irisSession = irisExerciseChatSessionService.createChatSessionForProgrammingExercise(exercise, userUtilService.getUserByLogin(TEST_PREFIX + "student2")); + irisExerciseChatSessionService.createChatSessionForProgrammingExercise(soloExercise, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); + var irisSession = irisExerciseChatSessionService.createChatSessionForProgrammingExercise(soloExercise, userUtilService.getUserByLogin(TEST_PREFIX + "student2")); IrisMessage messageToSend = createDefaultMockMessage(irisSession); request.postWithoutResponseBody("/api/iris/sessions/" + irisSession.getId() + "/messages", messageToSend, HttpStatus.FORBIDDEN); } @@ -180,7 +245,7 @@ void sendOneMessageToWrongSession() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") void sendMessageWithoutContent() throws Exception { - var irisSession = irisExerciseChatSessionService.createChatSessionForProgrammingExercise(exercise, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); + var irisSession = irisExerciseChatSessionService.createChatSessionForProgrammingExercise(soloExercise, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); var messageToSend = irisSession.newMessage(); request.postWithoutResponseBody("/api/iris/sessions/" + irisSession.getId() + "/messages", messageToSend, HttpStatus.BAD_REQUEST); } @@ -188,7 +253,7 @@ void sendMessageWithoutContent() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") void sendTwoMessages() throws Exception { - var irisSession = irisExerciseChatSessionService.createChatSessionForProgrammingExercise(exercise, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); + var irisSession = irisExerciseChatSessionService.createChatSessionForProgrammingExercise(soloExercise, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); IrisMessage messageToSend1 = createDefaultMockMessage(irisSession); irisRequestMockProvider.mockProgrammingExerciseChatResponse(dto -> { @@ -221,7 +286,7 @@ void sendTwoMessages() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") void getMessages() throws Exception { - var irisSession = irisExerciseChatSessionService.createChatSessionForProgrammingExercise(exercise, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); + var irisSession = irisExerciseChatSessionService.createChatSessionForProgrammingExercise(soloExercise, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); IrisMessage message1 = irisMessageService.saveMessage(createDefaultMockMessage(irisSession), irisSession, IrisMessageSender.USER); IrisMessage message2 = irisMessageService.saveMessage(createDefaultMockMessage(irisSession), irisSession, IrisMessageSender.LLM); @@ -235,7 +300,7 @@ void getMessages() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") void rateMessageHelpfulTrue() throws Exception { - var irisSession = irisExerciseChatSessionService.createChatSessionForProgrammingExercise(exercise, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); + var irisSession = irisExerciseChatSessionService.createChatSessionForProgrammingExercise(soloExercise, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); var message = irisSession.newMessage(); message.addContent(createMockTextContent()); var irisMessage = irisMessageService.saveMessage(message, irisSession, IrisMessageSender.LLM); @@ -247,7 +312,7 @@ void rateMessageHelpfulTrue() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") void rateMessageHelpfulFalse() throws Exception { - var irisSession = irisExerciseChatSessionService.createChatSessionForProgrammingExercise(exercise, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); + var irisSession = irisExerciseChatSessionService.createChatSessionForProgrammingExercise(soloExercise, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); var message = irisSession.newMessage(); message.addContent(createMockTextContent()); var irisMessage = irisMessageService.saveMessage(message, irisSession, IrisMessageSender.LLM); @@ -259,7 +324,7 @@ void rateMessageHelpfulFalse() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") void rateMessageHelpfulNull() throws Exception { - var irisSession = irisExerciseChatSessionService.createChatSessionForProgrammingExercise(exercise, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); + var irisSession = irisExerciseChatSessionService.createChatSessionForProgrammingExercise(soloExercise, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); var message = irisSession.newMessage(); message.addContent(createMockTextContent()); var irisMessage = irisMessageService.saveMessage(message, irisSession, IrisMessageSender.LLM); @@ -271,7 +336,7 @@ void rateMessageHelpfulNull() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") void rateMessageWrongSender() throws Exception { - var irisSession = irisExerciseChatSessionService.createChatSessionForProgrammingExercise(exercise, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); + var irisSession = irisExerciseChatSessionService.createChatSessionForProgrammingExercise(soloExercise, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); var message = irisSession.newMessage(); message.addContent(createMockTextContent()); var irisMessage = irisMessageService.saveMessage(message, irisSession, IrisMessageSender.USER); @@ -281,8 +346,8 @@ void rateMessageWrongSender() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") void rateMessageWrongSession() throws Exception { - var irisSession1 = irisExerciseChatSessionService.createChatSessionForProgrammingExercise(exercise, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); - var irisSession2 = irisExerciseChatSessionService.createChatSessionForProgrammingExercise(exercise, userUtilService.getUserByLogin(TEST_PREFIX + "student2")); + var irisSession1 = irisExerciseChatSessionService.createChatSessionForProgrammingExercise(soloExercise, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); + var irisSession2 = irisExerciseChatSessionService.createChatSessionForProgrammingExercise(soloExercise, userUtilService.getUserByLogin(TEST_PREFIX + "student2")); var message = irisSession1.newMessage(); message.addContent(createMockTextContent()); var irisMessage = irisMessageService.saveMessage(message, irisSession1, IrisMessageSender.USER); @@ -292,7 +357,7 @@ void rateMessageWrongSession() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") void resendMessage() throws Exception { - var irisSession = irisExerciseChatSessionService.createChatSessionForProgrammingExercise(exercise, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); + var irisSession = irisExerciseChatSessionService.createChatSessionForProgrammingExercise(soloExercise, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); var messageToSend = createDefaultMockMessage(irisSession); irisRequestMockProvider.mockProgrammingExerciseChatResponse(dto -> { @@ -312,9 +377,9 @@ void resendMessage() throws Exception { // User needs to be Admin to change settings @Test - @WithMockUser(username = TEST_PREFIX + "student2", roles = "ADMIN") + @WithMockUser(username = TEST_PREFIX + "student3", roles = "ADMIN") void sendMessageRateLimitReached() throws Exception { - var irisSession = irisExerciseChatSessionService.createChatSessionForProgrammingExercise(exercise, userUtilService.getUserByLogin(TEST_PREFIX + "student2")); + var irisSession = irisExerciseChatSessionService.createChatSessionForProgrammingExercise(soloExercise, userUtilService.getUserByLogin(TEST_PREFIX + "student3")); var messageToSend1 = createDefaultMockMessage(irisSession); var messageToSend2 = createDefaultMockMessage(irisSession); 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 3/3] 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; +}