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/PyrisWebhookService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisWebhookService.java
index 2138b8789b0b..395932a7157a 100644
--- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisWebhookService.java
+++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisWebhookService.java
@@ -94,15 +94,16 @@ private PyrisLectureUnitWebhookDTO processAttachmentForUpdate(AttachmentUnit att
String courseTitle = attachmentUnit.getLecture().getCourse().getTitle();
String courseDescription = attachmentUnit.getLecture().getCourse().getDescription() == null ? "" : attachmentUnit.getLecture().getCourse().getDescription();
String base64EncodedPdf = attachmentToBase64(attachmentUnit);
+ String lectureUnitLink = artemisBaseUrl + attachmentUnit.getAttachment().getLink();
lectureUnitRepository.save(attachmentUnit);
- return new PyrisLectureUnitWebhookDTO(base64EncodedPdf, lectureUnitId, lectureUnitName, lectureId, lectureTitle, courseId, courseTitle, courseDescription);
+ return new PyrisLectureUnitWebhookDTO(base64EncodedPdf, lectureUnitId, lectureUnitName, lectureId, lectureTitle, courseId, courseTitle, courseDescription, lectureUnitLink);
}
private PyrisLectureUnitWebhookDTO processAttachmentForDeletion(AttachmentUnit attachmentUnit) {
Long lectureUnitId = attachmentUnit.getId();
Long lectureId = attachmentUnit.getLecture().getId();
Long courseId = attachmentUnit.getLecture().getCourse().getId();
- return new PyrisLectureUnitWebhookDTO("", lectureUnitId, "", lectureId, "", courseId, "", "");
+ return new PyrisLectureUnitWebhookDTO("", lectureUnitId, "", lectureId, "", courseId, "", "", "");
}
/**
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/dto/lectureingestionwebhook/PyrisLectureUnitWebhookDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/lectureingestionwebhook/PyrisLectureUnitWebhookDTO.java
index b2f2cde1019d..90985390a40a 100644
--- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/lectureingestionwebhook/PyrisLectureUnitWebhookDTO.java
+++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/lectureingestionwebhook/PyrisLectureUnitWebhookDTO.java
@@ -8,6 +8,7 @@
* providing necessary details such as lecture and course identifiers, names, and descriptions.
*/
@JsonInclude(JsonInclude.Include.NON_EMPTY)
+
public record PyrisLectureUnitWebhookDTO(String pdfFile, long lectureUnitId, String lectureUnitName, long lectureId, String lectureName, long courseId, String courseName,
- String courseDescription) {
+ String courseDescription, String lectureUnitLink) {
}
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 d2743c2e71a5..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
@@ -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
@@ -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
@@ -134,13 +133,13 @@ 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();
user.hasAcceptedIrisElseThrow();
var session = getCurrentSessionOrCreateIfNotExistsInternal(course, user, false);
- CompletableFuture.runAsync(() -> requestAndHandleResponse(session, "jol", competencyJol));
+ CompletableFuture.runAsync(() -> requestAndHandleResponse(session, "default", competencyJol));
}
/**
@@ -154,7 +153,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 +183,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/session/IrisExerciseChatSessionService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisExerciseChatSessionService.java
index a51f1730e98c..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
@@ -2,31 +2,44 @@
import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_IRIS;
+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;
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;
@@ -39,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;
@@ -57,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;
@@ -72,6 +91,8 @@ public IrisExerciseChatSessionService(IrisMessageService irisMessageService, LLM
this.rateLimitService = rateLimitService;
this.pyrisPipelineService = pyrisPipelineService;
this.programmingExerciseRepository = programmingExerciseRepository;
+ this.irisExerciseChatSessionRepository = irisExerciseChatSessionRepository;
+ this.submissionRepository = submissionRepository;
}
/**
@@ -81,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));
}
@@ -126,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");
@@ -140,11 +171,94 @@ 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) {
- 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();
}
@@ -152,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 6047631fb5bf..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
@@ -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;
@@ -40,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;
@@ -83,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();
@@ -107,6 +109,7 @@ private void createInitialGlobalSettings() {
initializeIrisChatSettings(settings);
initializeIrisTextExerciseChatSettings(settings);
+ initializeIrisCourseChatSettings(settings);
initializeIrisLectureIngestionSettings(settings);
initializeIrisCompetencyGenerationSettings(settings);
@@ -135,6 +138,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 +216,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 +236,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 +287,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 +448,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);
@@ -458,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.
*
@@ -482,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.
@@ -507,10 +581,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 +607,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 +634,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 +664,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;
}
@@ -606,6 +684,7 @@ public IrisExerciseSettings getDefaultSettingsFor(Exercise exercise) {
settings.setExercise(exercise);
settings.setIrisChatSettings(new IrisChatSubSettings());
settings.setIrisTextExerciseChatSettings(new IrisTextExerciseChatSubSettings());
+
return settings;
}
@@ -664,8 +743,38 @@ 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();
};
}
+
+ /**
+ * 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 2c284b6ea1f8..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
@@ -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;
@@ -51,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.
@@ -80,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));
@@ -123,6 +133,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 +265,26 @@ 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;
+ var disabledForEvents = !minimal ? getCombinedDisabledForEvents(settingsList, IrisSettings::getIrisChatSettings) : null;
+
+ return new IrisCombinedChatSubSettingsDTO(enabled, rateLimit, null, allowedVariants, selectedVariant, enabledForCategories, disabledForEvents);
+ }
+
/**
* 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 +312,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);
}
/**
@@ -299,7 +359,7 @@ public IrisCombinedCompetencyGenerationSubSettingsDTO combineCompetencyGeneratio
* @param subSettingsFunction Function to get the sub settings from an IrisSettings object.
* @return Combined enabled field.
*/
- private boolean getCombinedEnabled(List settingsList, Function subSettingsFunction) {
+ private boolean getCombinedEnabled(List settingsList, Function subSettingsFunction) {
for (var irisSettings : settingsList) {
if (irisSettings == null) {
return false;
@@ -350,9 +410,34 @@ 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)
.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/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