diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/FaqService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/FaqService.java new file mode 100644 index 000000000000..242d2eb789f5 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/FaqService.java @@ -0,0 +1,85 @@ +package de.tum.cit.aet.artemis.communication.service; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.util.Optional; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import de.tum.cit.aet.artemis.communication.domain.Faq; +import de.tum.cit.aet.artemis.communication.domain.FaqState; +import de.tum.cit.aet.artemis.communication.repository.FaqRepository; +import de.tum.cit.aet.artemis.core.service.ProfileService; +import de.tum.cit.aet.artemis.iris.service.pyris.PyrisWebhookService; + +@Profile(PROFILE_CORE) +@Service +public class FaqService { + + private final Optional pyrisWebhookService; + + private final FaqRepository faqRepository; + + public FaqService(FaqRepository faqRepository, Optional pyrisWebhookService, ProfileService profileService) { + + this.pyrisWebhookService = pyrisWebhookService; + this.faqRepository = faqRepository; + + } + + /** + * Ingests FAQs into the Pyris system. If a specific FAQ ID is provided, the method will attempt to add + * that FAQ to Pyris. Otherwise, it will ingest all FAQs for the specified course that are in the "ACCEPTED" state. + * If the PyrisWebhookService is unavailable, the method does nothing. + * + * @param courseId the ID of the course for which FAQs will be ingested + * @param faqId an optional ID of a specific FAQ to ingest; if not provided, all accepted FAQs for the course are processed + * @throws IllegalArgumentException if a specific FAQ is provided but its state is not "ACCEPTED" + */ + public void ingestFaqsIntoPyris(Long courseId, Optional faqId) { + if (pyrisWebhookService.isEmpty()) { + return; + } + + faqId.ifPresentOrElse(id -> { + Faq faq = faqRepository.findById(id).orElseThrow(); + if (faq.getFaqState() != FaqState.ACCEPTED) { + throw new IllegalArgumentException("Faq is not in the state accepted, you cannot ingest this faq"); + } + pyrisWebhookService.get().addFaq(faq); + }, () -> faqRepository.findAllByCourseIdAndFaqState(courseId, FaqState.ACCEPTED).forEach(faq -> pyrisWebhookService.get().addFaq(faq))); + } + + /** + * Deletes an existing FAQ from the Pyris system. If the PyrisWebhookService is unavailable, the method does nothing. + * + * @param existingFaq the FAQ to be removed from Pyris + */ + public void deleteFaqInPyris(Faq existingFaq) { + if (pyrisWebhookService.isEmpty()) { + return; + } + + pyrisWebhookService.get().deleteFaq(existingFaq); + } + + /** + * Automatically updates or ingests a specific FAQ into the Pyris system for a given course. + * If the PyrisWebhookService is unavailable, the method does nothing. + * + * @param courseId the ID of the course to which the FAQ belongs + * @param faq the FAQ to be ingested or updated in Pyris + */ + public void autoIngestFaqsIntoPyris(Long courseId, Faq faq) { + if (pyrisWebhookService.isEmpty()) { + return; + } + + if (faq.getFaqState() != FaqState.ACCEPTED) { + return; + } + + pyrisWebhookService.get().autoUpdateFaqInPyris(courseId, faq); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/web/FaqResource.java b/src/main/java/de/tum/cit/aet/artemis/communication/web/FaqResource.java index 6983133ce2d1..419de87f37eb 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/web/FaqResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/web/FaqResource.java @@ -1,10 +1,12 @@ package de.tum.cit.aet.artemis.communication.web; import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_IRIS; import java.net.URI; import java.net.URISyntaxException; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -20,12 +22,14 @@ import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import de.tum.cit.aet.artemis.communication.domain.Faq; import de.tum.cit.aet.artemis.communication.domain.FaqState; import de.tum.cit.aet.artemis.communication.dto.FaqDTO; import de.tum.cit.aet.artemis.communication.repository.FaqRepository; +import de.tum.cit.aet.artemis.communication.service.FaqService; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.exception.AccessForbiddenException; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; @@ -58,10 +62,13 @@ public class FaqResource { private final FaqRepository faqRepository; - public FaqResource(CourseRepository courseRepository, AuthorizationCheckService authCheckService, FaqRepository faqRepository) { + private final FaqService faqService; + + public FaqResource(CourseRepository courseRepository, AuthorizationCheckService authCheckService, FaqRepository faqRepository, FaqService faqService) { this.faqRepository = faqRepository; this.courseRepository = courseRepository; this.authCheckService = authCheckService; + this.faqService = faqService; } /** @@ -86,6 +93,7 @@ public ResponseEntity createFaq(@RequestBody Faq faq, @PathVariable Long } Faq savedFaq = faqRepository.save(faq); FaqDTO dto = new FaqDTO(savedFaq); + faqService.autoIngestFaqsIntoPyris(courseId, savedFaq); return ResponseEntity.created(new URI("/api/courses/" + courseId + "/faqs/" + savedFaq.getId())).body(dto); } @@ -112,6 +120,7 @@ public ResponseEntity updateFaq(@RequestBody Faq faq, @PathVariable Long throw new BadRequestAlertException("Course ID of the FAQ provided courseID must match", ENTITY_NAME, "idNull"); } Faq updatedFaq = faqRepository.save(faq); + faqService.autoIngestFaqsIntoPyris(courseId, updatedFaq); FaqDTO dto = new FaqDTO(updatedFaq); return ResponseEntity.ok().body(dto); } @@ -152,6 +161,7 @@ public ResponseEntity deleteFaq(@PathVariable Long faqId, @PathVariable Lo if (!Objects.equals(existingFaq.getCourse().getId(), courseId)) { throw new BadRequestAlertException("Course ID of the FAQ provided courseID must match", ENTITY_NAME, "idNull"); } + faqService.deleteFaqInPyris(existingFaq); faqRepository.deleteById(faqId); return ResponseEntity.ok().headers(HeaderUtil.createEntityDeletionAlert(applicationName, true, ENTITY_NAME, faqId.toString())).build(); } @@ -212,6 +222,23 @@ public ResponseEntity> getFaqCategoriesForCourseByState(@PathVariabl return ResponseEntity.ok().body(faqs); } + /** + * POST /courses/{courseId}/ingest + * This endpoint is for starting the ingestion of all faqs or only one faq when triggered in Artemis. + * + * @param courseId the ID of the course for which all faqs should be ingested in pyris + * @param faqId If this id is present then only ingest this one faq of the respective course + * @return the ResponseEntity with status 200 (OK) and a message success or null if the operation failed + */ + @Profile(PROFILE_IRIS) + @PostMapping("courses/{courseId}/faqs/ingest") + @EnforceAtLeastInstructorInCourse + public ResponseEntity ingestFaqInIris(@PathVariable Long courseId, @RequestParam(required = false) Optional faqId) { + Course course = courseRepository.findByIdElseThrow(courseId); + faqService.ingestFaqsIntoPyris(courseId, faqId); + return ResponseEntity.ok().build(); + } + /** * @param courseId the id of the course the faq belongs to * @param role the required role of the user @@ -235,4 +262,8 @@ private void checkIsInstructorForAcceptedFaq(FaqState faqState, Long courseId) { } } + private boolean checkIfFaqIsAccepted(Faq faq) { + return faq.getFaqState() == FaqState.ACCEPTED; + } + } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/service/ProfileService.java b/src/main/java/de/tum/cit/aet/artemis/core/service/ProfileService.java index e142d9f82026..d98b27096b80 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/service/ProfileService.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/service/ProfileService.java @@ -113,6 +113,15 @@ public boolean isAeolusActive() { return isProfileActive(Constants.PROFILE_AEOLUS); } + /** + * Checks if the IRIS profile is active + * + * @return true if the aeolus profile is active, false otherwise + */ + public boolean isIrisActive() { + return isProfileActive(Constants.PROFILE_IRIS); + } + /** * Checks if the lti profile is active * 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 8320f2b6d708..64a775bb711e 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 @@ -40,6 +40,10 @@ public class IrisCourseSettings extends IrisSettings { @JoinColumn(name = "iris_lecture_ingestion_settings_id") private IrisLectureIngestionSubSettings irisLectureIngestionSettings; + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) + @JoinColumn(name = "iris_faq_ingestion_settings_id") + private IrisFaqIngestionSubSettings irisFaqIngestionSettings; + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER, optional = false) @JoinColumn(name = "iris_competency_generation_settings_id") private IrisCompetencyGenerationSubSettings irisCompetencyGenerationSettings; @@ -101,4 +105,14 @@ public IrisCompetencyGenerationSubSettings getIrisCompetencyGenerationSettings() public void setIrisCompetencyGenerationSettings(IrisCompetencyGenerationSubSettings irisCompetencyGenerationSubSettings) { this.irisCompetencyGenerationSettings = irisCompetencyGenerationSubSettings; } + + @Override + public IrisFaqIngestionSubSettings getIrisFaqIngestionSettings() { + return irisFaqIngestionSettings; + } + + @Override + public void setIrisFaqIngestionSettings(IrisFaqIngestionSubSettings irisFaqIngestionSubSettings) { + this.irisFaqIngestionSettings = irisFaqIngestionSubSettings; + } } 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 8048a76e976b..ca1b6fc09ef7 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 @@ -89,4 +89,16 @@ public IrisCompetencyGenerationSubSettings getIrisCompetencyGenerationSettings() public void setIrisCompetencyGenerationSettings(IrisCompetencyGenerationSubSettings irisCompetencyGenerationSubSettings) { } + + @Override + public IrisFaqIngestionSubSettings getIrisFaqIngestionSettings() { + // Empty because exercises don't have exercise faq settings + return null; + } + + @Override + public void setIrisFaqIngestionSettings(IrisFaqIngestionSubSettings irisFaqIngestionSubSettings) { + // Empty because exercises don't have exercise faq settings + + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisFaqIngestionSubSettings.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisFaqIngestionSubSettings.java new file mode 100644 index 000000000000..fe344e1b8d97 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisFaqIngestionSubSettings.java @@ -0,0 +1,29 @@ +package de.tum.cit.aet.artemis.iris.domain.settings; + +import jakarta.persistence.Column; +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * Represents the specific ingestion sub-settings of faqs for Iris. + * This class extends {@link IrisSubSettings} to provide settings required for faq data ingestion. + */ +@Entity +@DiscriminatorValue("FAQ_INGESTION") +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public class IrisFaqIngestionSubSettings extends IrisSubSettings { + + @Column(name = "auto_ingest_on_faq_creation") + private boolean autoIngestOnFaqCreation; + + public boolean getAutoIngestOnFaqCreation() { + return autoIngestOnFaqCreation; + } + + public void setAutoIngestOnFaqCreation(boolean autoIngestOnFaqCreation) { + this.autoIngestOnFaqCreation = autoIngestOnFaqCreation; + } + +} 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 5531f65584ff..fdfe1c6ed629 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 @@ -35,6 +35,10 @@ public class IrisGlobalSettings extends IrisSettings { @JoinColumn(name = "iris_lecture_ingestion_settings_id") private IrisLectureIngestionSubSettings irisLectureIngestionSettings; + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER, optional = false) + @JoinColumn(name = "iris_faq_ingestion_settings_id") + private IrisFaqIngestionSubSettings irisFaqIngestionSubSettings; + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER, optional = false) @JoinColumn(name = "iris_competency_generation_settings_id") private IrisCompetencyGenerationSubSettings irisCompetencyGenerationSettings; @@ -88,4 +92,15 @@ public IrisCompetencyGenerationSubSettings getIrisCompetencyGenerationSettings() public void setIrisCompetencyGenerationSettings(IrisCompetencyGenerationSubSettings irisCompetencyGenerationSettings) { this.irisCompetencyGenerationSettings = irisCompetencyGenerationSettings; } + + @Override + public IrisFaqIngestionSubSettings getIrisFaqIngestionSettings() { + return irisFaqIngestionSubSettings; + } + + @Override + public void setIrisFaqIngestionSettings(IrisFaqIngestionSubSettings irisFaqIngestionSubSettings) { + this.irisFaqIngestionSubSettings = irisFaqIngestionSubSettings; + + } } 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 d67d49caeab0..cf05343a5f0e 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 @@ -60,4 +60,9 @@ public abstract class IrisSettings extends DomainObject { public abstract IrisCompetencyGenerationSubSettings getIrisCompetencyGenerationSettings(); public abstract void setIrisCompetencyGenerationSettings(IrisCompetencyGenerationSubSettings irisCompetencyGenerationSubSettings); + + public abstract IrisFaqIngestionSubSettings getIrisFaqIngestionSettings(); + + public abstract void setIrisFaqIngestionSettings(IrisFaqIngestionSubSettings irisFaqIngestionSubSettings); + } 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 86e77fc9c034..52ee9f0e94a1 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 @@ -42,7 +42,8 @@ @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") + @JsonSubTypes.Type(value = IrisCompetencyGenerationSubSettings.class, name = "competency-generation"), + @JsonSubTypes.Type(value = IrisFaqIngestionSubSettings.class, name = "faq-ingestion"), }) // @formatter:on @JsonInclude(JsonInclude.Include.NON_EMPTY) 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 fe3561f12c2a..6e08e3dbca18 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 @@ -2,5 +2,5 @@ public enum IrisSubSettingsType { CHAT, // TODO: Rename to PROGRAMMING_EXERCISE_CHAT - TEXT_EXERCISE_CHAT, COURSE_CHAT, COMPETENCY_GENERATION, LECTURE_INGESTION + TEXT_EXERCISE_CHAT, COURSE_CHAT, COMPETENCY_GENERATION, LECTURE_INGESTION, FAQ_INGESTION } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedFaqIngestionSubSettingsDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedFaqIngestionSubSettingsDTO.java new file mode 100644 index 000000000000..ab9ba9b036c5 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedFaqIngestionSubSettingsDTO.java @@ -0,0 +1,7 @@ +package de.tum.cit.aet.artemis.iris.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record IrisCombinedFaqIngestionSubSettingsDTO(boolean enabled) { +} 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 294f2e836140..f0f04e582ae5 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 @@ -9,6 +9,7 @@ public record IrisCombinedSettingsDTO( IrisCombinedTextExerciseChatSubSettingsDTO irisTextExerciseChatSettings, IrisCombinedCourseChatSubSettingsDTO irisCourseChatSettings, IrisCombinedLectureIngestionSubSettingsDTO irisLectureIngestionSettings, - IrisCombinedCompetencyGenerationSubSettingsDTO irisCompetencyGenerationSettings + IrisCombinedCompetencyGenerationSubSettingsDTO irisCompetencyGenerationSettings, + IrisCombinedFaqIngestionSubSettingsDTO irisFaqIngestionSettings ) {} // @formatter:on diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/repository/IrisSettingsRepository.java b/src/main/java/de/tum/cit/aet/artemis/iris/repository/IrisSettingsRepository.java index c9e8eafba618..e6211b3e0d7c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/repository/IrisSettingsRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/repository/IrisSettingsRepository.java @@ -45,7 +45,8 @@ default IrisGlobalSettings findGlobalSettingsElseThrow() { LEFT JOIN FETCH irisSettings.irisTextExerciseChatSettings LEFT JOIN FETCH irisSettings.irisLectureIngestionSettings LEFT JOIN FETCH irisSettings.irisCompetencyGenerationSettings - WHERE irisSettings.course.id = :courseId + LEFT JOIN FETCH irisSettings.irisFaqIngestionSettings + WHERE irisSettings.course.id = :courseId """) Optional findCourseSettings(@Param("courseId") long courseId); 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 e29999073eb9..a964fa556ad3 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 @@ -29,9 +29,12 @@ import de.tum.cit.aet.artemis.iris.exception.IrisForbiddenException; import de.tum.cit.aet.artemis.iris.exception.IrisInternalPyrisErrorException; import de.tum.cit.aet.artemis.iris.service.pyris.dto.PyrisVariantDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.faqingestionwebhook.PyrisFaqWebhookDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.faqingestionwebhook.PyrisWebhookFaqDeletionExecutionDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.faqingestionwebhook.PyrisWebhookFaqIngestionExecutionDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.lectureingestionwebhook.PyrisWebhookLectureDeletionExecutionDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.lectureingestionwebhook.PyrisWebhookLectureIngestionExecutionDTO; -import de.tum.cit.aet.artemis.iris.service.pyris.job.IngestionWebhookJob; +import de.tum.cit.aet.artemis.iris.service.pyris.job.LectureIngestionWebhookJob; import de.tum.cit.aet.artemis.iris.web.open.PublicPyrisStatusUpdateResource; /** @@ -145,7 +148,7 @@ IngestionState getLectureUnitIngestionState(long courseId, long lectureId, long IngestionStateResponseDTO response = restTemplate.getForObject(url, IngestionStateResponseDTO.class); IngestionState state = response.state(); if (state != IngestionState.DONE) { - if (pyrisJobService.currentJobs().stream().filter(job -> job instanceof IngestionWebhookJob).map(job -> (IngestionWebhookJob) job) + if (pyrisJobService.currentJobs().stream().filter(job -> job instanceof LectureIngestionWebhookJob).map(job -> (LectureIngestionWebhookJob) job) .anyMatch(ingestionJob -> ingestionJob.courseId() == courseId && ingestionJob.lectureId() == lectureId && ingestionJob.lectureUnitId() == lectureUnitId)) { return IngestionState.IN_PROGRESS; } @@ -195,4 +198,66 @@ private String tryExtractErrorMessage(HttpStatusCodeException ex) { return ""; } } + + /** + * Executes a webhook and send faqs to the webhook with the given variant. This webhook adds an FAQ in the Pyris system. + * + * @param toUpdateFaq The DTO containing the faq to update + * @param executionDTO The DTO sent as a body for the execution + */ + public void executeFaqAdditionWebhook(PyrisFaqWebhookDTO toUpdateFaq, PyrisWebhookFaqIngestionExecutionDTO executionDTO) { + var endpoint = "/api/v1/webhooks/faqs"; + try { + restTemplate.postForEntity(pyrisUrl + endpoint, objectMapper.valueToTree(executionDTO), Void.class); + } + catch (HttpStatusCodeException e) { + log.error("Failed to send faq {} to Pyris: {}", toUpdateFaq.faqId(), e.getMessage()); + throw toIrisException(e); + } + catch (RestClientException | IllegalArgumentException e) { + log.error("Failed to send faq {} to Pyris: {}", toUpdateFaq.faqId(), e.getMessage()); + throw new PyrisConnectorException("Could not fetch response from Pyris"); + } + } + + /** + * Executes a webhook and adds faqs to the webhook with the given variant. This webhook deletes an FAQ in the Pyris system. + * + * @param executionDTO The DTO sent as a body for the execution + */ + public void executeFaqDeletionWebhook(PyrisWebhookFaqDeletionExecutionDTO executionDTO) { + var endpoint = "/api/v1/webhooks/faqs/delete"; + try { + restTemplate.postForEntity(pyrisUrl + endpoint, objectMapper.valueToTree(executionDTO), Void.class); + } + catch (HttpStatusCodeException e) { + log.error("Failed to send faqs to Pyris", e); + throw toIrisException(e); + } + catch (RestClientException | IllegalArgumentException e) { + log.error("Failed to send faqs to Pyris", e); + throw new PyrisConnectorException("Could not fetch response from Pyris"); + } + } + + /** + * Retrieves the ingestion state of the faq specified by retrieving the ingestion state from the vector database in Pyris. + * + * @param courseId id of the course + * @return The ingestion state of the faq + * + */ + IngestionState getFaqIngestionState(long courseId, long faqId) { + try { + String encodedBaseUrl = URLEncoder.encode(artemisBaseUrl, StandardCharsets.UTF_8); + String url = pyrisUrl + "/api/v1/courses/" + courseId + "/faqs/" + faqId + "/ingestion-state?base_url=" + encodedBaseUrl; + IngestionStateResponseDTO response = restTemplate.getForObject(url, IngestionStateResponseDTO.class); + return response.state(); + } + catch (RestClientException | IllegalArgumentException e) { + log.error("Error fetching ingestion state for faq {}", faqId, e); + throw new PyrisConnectorException("Error fetching ingestion state for faq" + faqId); + } + + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisJobService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisJobService.java index 46c0dd7547cd..b57e759e0df7 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisJobService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisJobService.java @@ -22,7 +22,8 @@ import de.tum.cit.aet.artemis.core.exception.ConflictException; import de.tum.cit.aet.artemis.iris.service.pyris.job.CourseChatJob; import de.tum.cit.aet.artemis.iris.service.pyris.job.ExerciseChatJob; -import de.tum.cit.aet.artemis.iris.service.pyris.job.IngestionWebhookJob; +import de.tum.cit.aet.artemis.iris.service.pyris.job.FaqIngestionWebhookJob; +import de.tum.cit.aet.artemis.iris.service.pyris.job.LectureIngestionWebhookJob; import de.tum.cit.aet.artemis.iris.service.pyris.job.PyrisJob; /** @@ -48,6 +49,9 @@ public class PyrisJobService { @Value("${artemis.iris.jobs.timeout:300}") private int jobTimeout; // in seconds + @Value("${artemis.iris.jobs.ingestion.timeout:3600}") + private int ingestionJobTimeout; // in seconds + public PyrisJobService(@Qualifier("hazelcastInstance") HazelcastInstance hazelcastInstance) { this.hazelcastInstance = hazelcastInstance; } @@ -92,19 +96,31 @@ public String addCourseChatJob(Long courseId, Long sessionId) { } /** - * Adds a new ingestion webhook job to the job map with a timeout. + * Adds a new lecture ingestion webhook job to the job map with a timeout. * * @param courseId the ID of the course associated with the webhook job * @param lectureId the ID of the lecture associated with the webhook job * @param lectureUnitId the ID of the lecture unit associated with the webhook job * @return a unique token identifying the created webhook job */ - public String addIngestionWebhookJob(long courseId, long lectureId, long lectureUnitId) { + public String addLectureIngestionWebhookJob(long courseId, long lectureId, long lectureUnitId) { + var token = generateJobIdToken(); + var job = new LectureIngestionWebhookJob(token, courseId, lectureId, lectureUnitId); + jobMap.put(token, job, ingestionJobTimeout, TimeUnit.SECONDS); + return token; + } + + /** + * Adds a new faq ingestion webhook job to the job map with a timeout. + * + * @param courseId the ID of the course associated with the webhook job + * @param faqId the ID of the faq associated with the webhook job + * @return a unique token identifying the created webhook job + */ + public String addFaqIngestionWebhookJob(long courseId, long faqId) { var token = generateJobIdToken(); - var job = new IngestionWebhookJob(token, courseId, lectureId, lectureUnitId); - long timeoutWebhookJob = 60; - TimeUnit unitWebhookJob = TimeUnit.MINUTES; - jobMap.put(token, job, timeoutWebhookJob, unitWebhookJob); + var job = new FaqIngestionWebhookJob(token, courseId, faqId); + jobMap.put(token, job, ingestionJobTimeout, TimeUnit.SECONDS); return token; } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisStatusUpdateService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisStatusUpdateService.java index 5a3e47776ea2..09455be4f785 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisStatusUpdateService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisStatusUpdateService.java @@ -14,6 +14,7 @@ import de.tum.cit.aet.artemis.iris.service.pyris.dto.chat.textexercise.PyrisTextExerciseChatStatusUpdateDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.competency.PyrisCompetencyStatusUpdateDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.consistencyCheck.PyrisConsistencyCheckStatusUpdateDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.faqingestionwebhook.PyrisFaqIngestionStatusUpdateDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.lectureingestionwebhook.PyrisLectureIngestionStatusUpdateDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.rewriting.PyrisRewritingStatusUpdateDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.status.PyrisStageDTO; @@ -22,7 +23,8 @@ import de.tum.cit.aet.artemis.iris.service.pyris.job.ConsistencyCheckJob; import de.tum.cit.aet.artemis.iris.service.pyris.job.CourseChatJob; import de.tum.cit.aet.artemis.iris.service.pyris.job.ExerciseChatJob; -import de.tum.cit.aet.artemis.iris.service.pyris.job.IngestionWebhookJob; +import de.tum.cit.aet.artemis.iris.service.pyris.job.FaqIngestionWebhookJob; +import de.tum.cit.aet.artemis.iris.service.pyris.job.LectureIngestionWebhookJob; import de.tum.cit.aet.artemis.iris.service.pyris.job.PyrisJob; import de.tum.cit.aet.artemis.iris.service.pyris.job.RewritingJob; import de.tum.cit.aet.artemis.iris.service.pyris.job.TextExerciseChatJob; @@ -163,7 +165,18 @@ private void removeJobIfTerminatedElseUpdate(List stages, PyrisJo * @param job the job that is updated * @param statusUpdate the status update */ - public void handleStatusUpdate(IngestionWebhookJob job, PyrisLectureIngestionStatusUpdateDTO statusUpdate) { + public void handleStatusUpdate(LectureIngestionWebhookJob job, PyrisLectureIngestionStatusUpdateDTO statusUpdate) { removeJobIfTerminatedElseUpdate(statusUpdate.stages(), job); } + + /** + * Handles the status update of a FAQ ingestion job. + * + * @param job the job that is updated + * @param statusUpdate the status update + */ + public void handleStatusUpdate(FaqIngestionWebhookJob job, PyrisFaqIngestionStatusUpdateDTO statusUpdate) { + removeJobIfTerminatedElseUpdate(statusUpdate.stages(), job); + } + } 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 395932a7157a..f6eed94dc71c 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 @@ -10,6 +10,7 @@ import java.util.Base64; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -19,6 +20,7 @@ import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; +import de.tum.cit.aet.artemis.communication.domain.Faq; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.domain.DomainObject; import de.tum.cit.aet.artemis.core.service.FilePathService; @@ -27,6 +29,9 @@ import de.tum.cit.aet.artemis.iris.exception.IrisInternalPyrisErrorException; import de.tum.cit.aet.artemis.iris.repository.IrisSettingsRepository; import de.tum.cit.aet.artemis.iris.service.pyris.dto.PyrisPipelineExecutionSettingsDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.faqingestionwebhook.PyrisFaqWebhookDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.faqingestionwebhook.PyrisWebhookFaqDeletionExecutionDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.faqingestionwebhook.PyrisWebhookFaqIngestionExecutionDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.lectureingestionwebhook.PyrisLectureUnitWebhookDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.lectureingestionwebhook.PyrisWebhookLectureDeletionExecutionDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.lectureingestionwebhook.PyrisWebhookLectureIngestionExecutionDTO; @@ -163,7 +168,7 @@ public String addLectureUnitToPyrisDB(AttachmentUnit attachmentUnit) { * @return jobToken if the job was created */ private String executeLectureDeletionWebhook(List toUpdateAttachmentUnits) { - String jobToken = pyrisJobService.addIngestionWebhookJob(0, 0, 0); + String jobToken = pyrisJobService.addLectureIngestionWebhookJob(0, 0, 0); PyrisPipelineExecutionSettingsDTO settingsDTO = new PyrisPipelineExecutionSettingsDTO(jobToken, List.of(), artemisBaseUrl); PyrisWebhookLectureDeletionExecutionDTO executionDTO = new PyrisWebhookLectureDeletionExecutionDTO(toUpdateAttachmentUnits, settingsDTO, List.of()); pyrisConnectorService.executeLectureDeletionWebhook(executionDTO); @@ -177,7 +182,8 @@ private String executeLectureDeletionWebhook(List to * @return jobToken if the job was created */ private String executeLectureAdditionWebhook(PyrisLectureUnitWebhookDTO toUpdateAttachmentUnit) { - String jobToken = pyrisJobService.addIngestionWebhookJob(toUpdateAttachmentUnit.courseId(), toUpdateAttachmentUnit.lectureId(), toUpdateAttachmentUnit.lectureUnitId()); + String jobToken = pyrisJobService.addLectureIngestionWebhookJob(toUpdateAttachmentUnit.courseId(), toUpdateAttachmentUnit.lectureId(), + toUpdateAttachmentUnit.lectureUnitId()); PyrisPipelineExecutionSettingsDTO settingsDTO = new PyrisPipelineExecutionSettingsDTO(jobToken, List.of(), artemisBaseUrl); PyrisWebhookLectureIngestionExecutionDTO executionDTO = new PyrisWebhookLectureIngestionExecutionDTO(toUpdateAttachmentUnit, settingsDTO, List.of()); pyrisConnectorService.executeLectureAddtionWebhook("fullIngestion", executionDTO); @@ -240,4 +246,88 @@ public Map getLectureUnitsIngestionState(long courseId, lo .collect(Collectors.toMap(DomainObject::getId, unit -> pyrisConnectorService.getLectureUnitIngestionState(courseId, lectureId, unit.getId()))); } + private boolean faqIngestionEnabled(Course course) { + var settings = irisSettingsService.getRawIrisSettingsFor(course).getIrisFaqIngestionSettings(); + return settings != null && settings.isEnabled(); + } + + /** + * send the updated / created faqs to Pyris for ingestion if autoLecturesUpdate is enabled. + * + * @param courseId Id of the course where the attachment is added + * @param newFaq the new faqs to be sent to pyris for ingestion + */ + public void autoUpdateFaqInPyris(Long courseId, Faq newFaq) { + IrisCourseSettings presentCourseSettings = null; + Optional courseSettings = irisSettingsRepository.findCourseSettings(courseId); + if (courseSettings.isPresent()) { + presentCourseSettings = courseSettings.get(); + } + + if (presentCourseSettings != null && presentCourseSettings.getIrisFaqIngestionSettings() != null && presentCourseSettings.getIrisFaqIngestionSettings().isEnabled() + && presentCourseSettings.getIrisFaqIngestionSettings().getAutoIngestOnFaqCreation()) { + addFaq(newFaq); + } + } + + /** + * adds the faq to Pyris. + * + * @param faq The faq that will be added to pyris + * @return jobToken if the job was created else null + */ + public String addFaq(Faq faq) { + if (faqIngestionEnabled(faq.getCourse())) { + return executeFaqAdditionWebhook(new PyrisFaqWebhookDTO(faq.getId(), faq.getQuestionTitle(), faq.getQuestionAnswer(), faq.getCourse().getId(), + faq.getCourse().getTitle(), faq.getCourse().getDescription())); + } + return null; + } + + /** + * executes the faq addition webhook to add faq to the vector database on pyris + * + * @param toUpdateFaq The faq that got Updated as webhook DTO + * @return jobToken if the job was created else null + */ + + private String executeFaqAdditionWebhook(PyrisFaqWebhookDTO toUpdateFaq) { + String jobToken = pyrisJobService.addFaqIngestionWebhookJob(toUpdateFaq.courseId(), toUpdateFaq.faqId()); + PyrisPipelineExecutionSettingsDTO settingsDTO = new PyrisPipelineExecutionSettingsDTO(jobToken, List.of(), artemisBaseUrl); + PyrisWebhookFaqIngestionExecutionDTO executionDTO = new PyrisWebhookFaqIngestionExecutionDTO(toUpdateFaq, settingsDTO, List.of()); + pyrisConnectorService.executeFaqAdditionWebhook(toUpdateFaq, executionDTO); + return jobToken; + + } + + /** + * delete the faqs in pyris + * + * @param faq The faqs that gets erased + * @return jobToken if the job was created + */ + public String deleteFaq(Faq faq) { + return executeFaqDeletionWebhook(new PyrisFaqWebhookDTO(faq.getId(), faq.getQuestionTitle(), faq.getQuestionAnswer(), faq.getCourse().getId(), faq.getCourse().getTitle(), + faq.getCourse().getDescription())); + + } + + /** + * executes the faq deletion webhook to delete faq from the vector database on pyris + * + * @param toUpdateFaqs The faq that got Updated as webhook DTO + * @return jobToken if the job was created else null + */ + private String executeFaqDeletionWebhook(PyrisFaqWebhookDTO toUpdateFaqs) { + String jobToken = pyrisJobService.addFaqIngestionWebhookJob(0, 0); + PyrisPipelineExecutionSettingsDTO settingsDTO = new PyrisPipelineExecutionSettingsDTO(jobToken, List.of(), artemisBaseUrl); + PyrisWebhookFaqDeletionExecutionDTO executionDTO = new PyrisWebhookFaqDeletionExecutionDTO(toUpdateFaqs, settingsDTO, List.of()); + pyrisConnectorService.executeFaqDeletionWebhook(executionDTO); + return jobToken; + } + + public IngestionState getFaqIngestionState(long courseId, long faqId) { + return pyrisConnectorService.getFaqIngestionState(courseId, faqId); + } + } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/faqingestionwebhook/PyrisFaqIngestionStatusUpdateDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/faqingestionwebhook/PyrisFaqIngestionStatusUpdateDTO.java new file mode 100644 index 000000000000..fd1462a6eb58 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/faqingestionwebhook/PyrisFaqIngestionStatusUpdateDTO.java @@ -0,0 +1,11 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.dto.faqingestionwebhook; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.iris.service.pyris.dto.status.PyrisStageDTO; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record PyrisFaqIngestionStatusUpdateDTO(String result, List stages, long jobId) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/faqingestionwebhook/PyrisFaqWebhookDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/faqingestionwebhook/PyrisFaqWebhookDTO.java new file mode 100644 index 000000000000..4387daa03364 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/faqingestionwebhook/PyrisFaqWebhookDTO.java @@ -0,0 +1,13 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.dto.faqingestionwebhook; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * Represents a webhook data transfer object for an FAQ in the Pyris system. + * This DTO is used to encapsulate the information related to the faqs + * providing necessary details such as faqId the content as questionTitle and questionAnswer as well as the course description. + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) + +public record PyrisFaqWebhookDTO(long faqId, String questionTitle, String questionAnswer, long courseId, String courseName, String courseDescription) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/faqingestionwebhook/PyrisWebhookFaqDeletionExecutionDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/faqingestionwebhook/PyrisWebhookFaqDeletionExecutionDTO.java new file mode 100644 index 000000000000..a98b8568d699 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/faqingestionwebhook/PyrisWebhookFaqDeletionExecutionDTO.java @@ -0,0 +1,12 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.dto.faqingestionwebhook; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.iris.service.pyris.dto.PyrisPipelineExecutionSettingsDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.status.PyrisStageDTO; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record PyrisWebhookFaqDeletionExecutionDTO(PyrisFaqWebhookDTO pyrisFaqWebhookDTO, PyrisPipelineExecutionSettingsDTO settings, List initialStages) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/faqingestionwebhook/PyrisWebhookFaqIngestionExecutionDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/faqingestionwebhook/PyrisWebhookFaqIngestionExecutionDTO.java new file mode 100644 index 000000000000..fb7f18c73989 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/faqingestionwebhook/PyrisWebhookFaqIngestionExecutionDTO.java @@ -0,0 +1,12 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.dto.faqingestionwebhook; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.iris.service.pyris.dto.PyrisPipelineExecutionSettingsDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.status.PyrisStageDTO; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record PyrisWebhookFaqIngestionExecutionDTO(PyrisFaqWebhookDTO pyrisFaqWebhookDTO, PyrisPipelineExecutionSettingsDTO settings, List initialStages) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/FaqIngestionWebhookJob.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/FaqIngestionWebhookJob.java new file mode 100644 index 000000000000..324e970aef85 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/FaqIngestionWebhookJob.java @@ -0,0 +1,21 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.job; + +import de.tum.cit.aet.artemis.core.domain.Course; +import de.tum.cit.aet.artemis.exercise.domain.Exercise; + +/** + * An implementation of a PyrisJob for Faq Ingestion in Pyris. + * This job is used to reference the details of then Ingestion when Pyris sends a status update. + */ +public record FaqIngestionWebhookJob(String jobId, long courseId, long faqId) implements PyrisJob { + + @Override + public boolean canAccess(Course course) { + return false; + } + + @Override + public boolean canAccess(Exercise exercise) { + return false; + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/IngestionWebhookJob.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/LectureIngestionWebhookJob.java similarity index 80% rename from src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/IngestionWebhookJob.java rename to src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/LectureIngestionWebhookJob.java index 5dbfe0955520..d4fe8171ad5a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/IngestionWebhookJob.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/LectureIngestionWebhookJob.java @@ -7,7 +7,7 @@ * An implementation of a PyrisJob for Lecture Ingestion in Pyris. * This job is used to reference the details of then Ingestion when Pyris sends a status update. */ -public record IngestionWebhookJob(String jobId, long courseId, long lectureId, long lectureUnitId) implements PyrisJob { +public record LectureIngestionWebhookJob(String jobId, long courseId, long lectureId, long lectureUnitId) implements PyrisJob { @Override public boolean canAccess(Course course) { 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 8ed823adee2c..a729345e396b 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 @@ -35,6 +35,7 @@ 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.IrisFaqIngestionSubSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisGlobalSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisLectureIngestionSubSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisSettings; @@ -112,6 +113,7 @@ private void createInitialGlobalSettings() { initializeIrisCourseChatSettings(settings); initializeIrisLectureIngestionSettings(settings); initializeIrisCompetencyGenerationSettings(settings); + initializeIrisFaqIngestionSettings(settings); irisSettingsRepository.save(settings); } @@ -156,10 +158,28 @@ private void initializeIrisCompetencyGenerationSettings(IrisGlobalSettings setti settings.setIrisCompetencyGenerationSettings(irisCompetencyGenerationSettings); } + /** + * Get the combined Iris settings for a course. + * Combines the global settings with the course settings. + * + * @return The combined Iris settings for the course + */ public IrisGlobalSettings getGlobalSettings() { return irisSettingsRepository.findGlobalSettingsElseThrow(); } + /** + * This method initializes the Iris faq settings for a course. + * + * @param settings The course settings + * @return The combined Iris settings for the course + */ + private void initializeIrisFaqIngestionSettings(IrisGlobalSettings settings) { + var irisFaqIngestionSubSettings = settings.getIrisFaqIngestionSettings(); + irisFaqIngestionSubSettings = initializeSettings(irisFaqIngestionSubSettings, IrisFaqIngestionSubSettings::new); + settings.setIrisFaqIngestionSettings(irisFaqIngestionSubSettings); + } + /** * Save the Iris settings. Should always be used over directly calling the repository. * Automatically decides whether to save a new Iris settings object or update an existing one. @@ -266,6 +286,12 @@ private IrisGlobalSettings updateGlobalSettings(IrisGlobalSettings existingSetti null, GLOBAL )); + existingSettings.setIrisFaqIngestionSettings(irisSubSettingsService.update( + existingSettings.getIrisFaqIngestionSettings(), + settingsUpdate.getIrisFaqIngestionSettings(), + null, + GLOBAL + )); // @formatter:on return irisSettingsRepository.save(existingSettings); @@ -310,6 +336,12 @@ private IrisCourseSettings updateCourseSettings(IrisCourseSettings existingSetti parentSettings.irisLectureIngestionSettings(), COURSE )); + existingSettings.setIrisFaqIngestionSettings(irisSubSettingsService.update( + existingSettings.getIrisFaqIngestionSettings(), + settingsUpdate.getIrisFaqIngestionSettings(), + parentSettings.irisFaqIngestionSettings(), + COURSE + )); existingSettings.setIrisCompetencyGenerationSettings(irisSubSettingsService.update( existingSettings.getIrisCompetencyGenerationSettings(), settingsUpdate.getIrisCompetencyGenerationSettings(), @@ -585,7 +617,8 @@ public IrisCombinedSettingsDTO getCombinedIrisGlobalSettings() { irisSubSettingsService.combineTextExerciseChatSettings(settingsList, false), irisSubSettingsService.combineCourseChatSettings(settingsList, false), irisSubSettingsService.combineLectureIngestionSubSettings(settingsList, false), - irisSubSettingsService.combineCompetencyGenerationSettings(settingsList, false) + irisSubSettingsService.combineCompetencyGenerationSettings(settingsList, false), + irisSubSettingsService.combineFaqIngestionSubSettings(settingsList, false) ); // @formatter:on } @@ -611,7 +644,8 @@ public IrisCombinedSettingsDTO getCombinedIrisSettingsFor(Course course, boolean irisSubSettingsService.combineTextExerciseChatSettings(settingsList, minimal), irisSubSettingsService.combineCourseChatSettings(settingsList, minimal), irisSubSettingsService.combineLectureIngestionSubSettings(settingsList, minimal), - irisSubSettingsService.combineCompetencyGenerationSettings(settingsList, minimal) + irisSubSettingsService.combineCompetencyGenerationSettings(settingsList, minimal), + irisSubSettingsService.combineFaqIngestionSubSettings(settingsList, minimal) ); // @formatter:on } @@ -638,7 +672,8 @@ public IrisCombinedSettingsDTO getCombinedIrisSettingsFor(Exercise exercise, boo irisSubSettingsService.combineTextExerciseChatSettings(settingsList, minimal), irisSubSettingsService.combineCourseChatSettings(settingsList, minimal), irisSubSettingsService.combineLectureIngestionSubSettings(settingsList, minimal), - irisSubSettingsService.combineCompetencyGenerationSettings(settingsList, minimal) + irisSubSettingsService.combineCompetencyGenerationSettings(settingsList, minimal), + irisSubSettingsService.combineFaqIngestionSubSettings(settingsList, minimal) ); // @formatter:on } @@ -669,6 +704,7 @@ public IrisCourseSettings getDefaultSettingsFor(Course course) { settings.setIrisCourseChatSettings(new IrisCourseChatSubSettings()); settings.setIrisLectureIngestionSettings(new IrisLectureIngestionSubSettings()); settings.setIrisCompetencyGenerationSettings(new IrisCompetencyGenerationSubSettings()); + settings.setIrisFaqIngestionSettings(new IrisFaqIngestionSubSettings()); return settings; } @@ -746,6 +782,7 @@ private boolean isFeatureEnabledInSettings(IrisCombinedSettingsDTO settings, Iri case COURSE_CHAT -> settings.irisCourseChatSettings().enabled(); case COMPETENCY_GENERATION -> settings.irisCompetencyGenerationSettings().enabled(); case LECTURE_INGESTION -> settings.irisLectureIngestionSettings().enabled(); + case FAQ_INGESTION -> settings.irisFaqIngestionSettings().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 6d0a03b002c2..e1a01ebdf00e 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 @@ -20,6 +20,7 @@ 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.IrisFaqIngestionSubSettings; 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.IrisSettingsType; @@ -28,6 +29,7 @@ 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.IrisCombinedFaqIngestionSubSettingsDTO; import de.tum.cit.aet.artemis.iris.dto.IrisCombinedLectureIngestionSubSettingsDTO; import de.tum.cit.aet.artemis.iris.dto.IrisCombinedTextExerciseChatSubSettingsDTO; @@ -195,6 +197,37 @@ public IrisLectureIngestionSubSettings update(IrisLectureIngestionSubSettings cu return currentSettings; } + /** + * Updates a FAQ 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). + * Special notes: if the new Settings are null, we will return null. That means the sub-settings will be deleted. + * + * @param currentSettings Current FAQ Ingestion sub settings. + * @param newSettings Updated FAQ Ingestion sub settings. + * @param parentSettings Parent FAQ Ingestion sub settings. + * @param settingsType Type of the settings the sub settings belong to. + * @return Updated FAQ Ingestion sub settings. + */ + public IrisFaqIngestionSubSettings update(IrisFaqIngestionSubSettings currentSettings, IrisFaqIngestionSubSettings newSettings, + IrisCombinedFaqIngestionSubSettingsDTO parentSettings, IrisSettingsType settingsType) { + if (newSettings == null) { + if (parentSettings == null) { + throw new IllegalArgumentException("Cannot delete the FAQ Ingestion settings"); + } + return null; + } + if (currentSettings == null) { + currentSettings = new IrisFaqIngestionSubSettings(); + } + + if (authCheckService.isAdmin() && (settingsType == IrisSettingsType.COURSE || settingsType == IrisSettingsType.GLOBAL)) { + currentSettings.setEnabled(newSettings.isEnabled()); + currentSettings.setAutoIngestOnFaqCreation(newSettings.getAutoIngestOnFaqCreation()); + } + + return currentSettings; + } + /** * Updates a Competency Generation 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). @@ -334,6 +367,20 @@ public IrisCombinedLectureIngestionSubSettingsDTO combineLectureIngestionSubSett return new IrisCombinedLectureIngestionSubSettingsDTO(enabled); } + /** + * Combines the FAQ Ingestion 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 Lecture Ingestion settings. + */ + public IrisCombinedFaqIngestionSubSettingsDTO combineFaqIngestionSubSettings(ArrayList settingsList, boolean minimal) { + var enabled = getCombinedEnabled(settingsList, IrisSettings::getIrisFaqIngestionSettings); + return new IrisCombinedFaqIngestionSubSettingsDTO(enabled); + } + /** * Combines the Competency Generation settings of multiple {@link IrisSettings} objects. * If minimal is true, the returned object will only contain the enabled field. diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisResource.java b/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisResource.java index 37e2983e39e0..64b15d06e2ab 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisResource.java @@ -127,4 +127,32 @@ public ResponseEntity> getStatusOfLectureUnitsIngestio } } + /** + * Retrieves the ingestion state of a specific FAQ in a course by communicating with Pyris. + * + *

+ * This method sends a GET request to the external Pyris service to fetch the current ingestion + * state of a FAQ, identified by its ID. It constructs a request using the provided + * `courseId` and `faqId` and returns the state of the ingestion process (e.g., NOT_STARTED, + * IN_PROGRESS, DONE, ERROR). + *

+ * + * @param courseId the ID of the course the FAQ belongs to + * @param faqId the ID of the FAQ for which the ingestion state is being requested + * @return a {@link ResponseEntity} containing a map with the {@link IngestionState} of the FAQ, + */ + @GetMapping("courses/{courseId}/faqs/{faqId}/ingestion-state") + @EnforceAtLeastInstructorInCourse + public ResponseEntity> getStatusOfFaqIngestion(@PathVariable long courseId, @PathVariable long faqId) { + try { + Course course = courseRepository.findByIdElseThrow(courseId); + Map responseMap = Map.of(faqId, pyrisWebhookService.getFaqIngestionState(courseId, faqId)); + return ResponseEntity.ok(responseMap); + } + catch (PyrisConnectorException e) { + log.error("Error fetching ingestion state for faq {}", faqId, e); + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build(); + } + } + } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/web/open/PublicPyrisStatusUpdateResource.java b/src/main/java/de/tum/cit/aet/artemis/iris/web/open/PublicPyrisStatusUpdateResource.java index 933a8ed99d11..d3b816bb7dd3 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/web/open/PublicPyrisStatusUpdateResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/web/open/PublicPyrisStatusUpdateResource.java @@ -23,13 +23,15 @@ import de.tum.cit.aet.artemis.iris.service.pyris.dto.chat.textexercise.PyrisTextExerciseChatStatusUpdateDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.competency.PyrisCompetencyStatusUpdateDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.consistencyCheck.PyrisConsistencyCheckStatusUpdateDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.faqingestionwebhook.PyrisFaqIngestionStatusUpdateDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.lectureingestionwebhook.PyrisLectureIngestionStatusUpdateDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.rewriting.PyrisRewritingStatusUpdateDTO; import de.tum.cit.aet.artemis.iris.service.pyris.job.CompetencyExtractionJob; import de.tum.cit.aet.artemis.iris.service.pyris.job.ConsistencyCheckJob; import de.tum.cit.aet.artemis.iris.service.pyris.job.CourseChatJob; import de.tum.cit.aet.artemis.iris.service.pyris.job.ExerciseChatJob; -import de.tum.cit.aet.artemis.iris.service.pyris.job.IngestionWebhookJob; +import de.tum.cit.aet.artemis.iris.service.pyris.job.FaqIngestionWebhookJob; +import de.tum.cit.aet.artemis.iris.service.pyris.job.LectureIngestionWebhookJob; import de.tum.cit.aet.artemis.iris.service.pyris.job.PyrisJob; import de.tum.cit.aet.artemis.iris.service.pyris.job.RewritingJob; import de.tum.cit.aet.artemis.iris.service.pyris.job.TextExerciseChatJob; @@ -221,11 +223,35 @@ public ResponseEntity setStatusOfIngestionJob(@PathVariable String runId, if (!job.jobId().equals(runId)) { throw new ConflictException("Run ID in URL does not match run ID in request body", "Job", "runIdMismatch"); } - if (!(job instanceof IngestionWebhookJob ingestionWebhookJob)) { + if (!(job instanceof LectureIngestionWebhookJob lectureIngestionWebhookJob)) { throw new ConflictException("Run ID is not an ingestion job", "Job", "invalidRunId"); } - pyrisStatusUpdateService.handleStatusUpdate(ingestionWebhookJob, statusUpdateDTO); + pyrisStatusUpdateService.handleStatusUpdate(lectureIngestionWebhookJob, statusUpdateDTO); + return ResponseEntity.ok().build(); + } + + /** + * {@code POST /api/public/pyris/webhooks/ingestion/faqs/runs/{runId}/status} : Set the status of an Ingestion job. + * + * @param runId the ID of the job + * @param statusUpdateDTO the status update + * @param request the HTTP request + * @return a {@link ResponseEntity} with status {@code 200 (OK)} + * @throws ConflictException if the run ID in the URL does not match the run ID in the request body + * @throws AccessForbiddenException if the token is invalid + */ + @PostMapping("webhooks/ingestion/faqs/runs/{runId}/status") + @EnforceNothing + public ResponseEntity setStatusOfFaqIngestionJob(@PathVariable String runId, @RequestBody PyrisFaqIngestionStatusUpdateDTO statusUpdateDTO, HttpServletRequest request) { + PyrisJob job = pyrisJobService.getAndAuthenticateJobFromHeaderElseThrow(request, PyrisJob.class); + if (!job.jobId().equals(runId)) { + throw new ConflictException("Run ID in URL does not match run ID in request body", "Job", "runIdMismatch"); + } + if (!(job instanceof FaqIngestionWebhookJob faqIngestionWebhookJob)) { + throw new ConflictException("Run ID is not an ingestion job", "Job", "invalidRunId"); + } + pyrisStatusUpdateService.handleStatusUpdate(faqIngestionWebhookJob, statusUpdateDTO); return ResponseEntity.ok().build(); } } diff --git a/src/main/resources/config/liquibase/changelog/20241217122200_changelog.xml b/src/main/resources/config/liquibase/changelog/20241217122200_changelog.xml new file mode 100644 index 000000000000..ddfd6f8a333c --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20241217122200_changelog.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml index c8c467f527a3..3cc6e0e003f1 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -41,9 +41,10 @@ - - + + + 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 270a2d5132e9..760895e64d45 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 @@ -3,6 +3,7 @@ import { IrisChatSubSettings, IrisCompetencyGenerationSubSettings, IrisCourseChatSubSettings, + IrisFaqIngestionSubSettings, IrisLectureIngestionSubSettings, IrisTextExerciseChatSubSettings, } from 'app/entities/iris/settings/iris-sub-settings.model'; @@ -21,6 +22,7 @@ export abstract class IrisSettings implements BaseEntity { irisCourseChatSettings?: IrisCourseChatSubSettings; irisLectureIngestionSettings?: IrisLectureIngestionSubSettings; irisCompetencyGenerationSettings?: IrisCompetencyGenerationSubSettings; + irisFaqIngestionSettings?: IrisFaqIngestionSubSettings; } export class IrisGlobalSettings implements IrisSettings { @@ -31,6 +33,7 @@ export class IrisGlobalSettings implements IrisSettings { irisCourseChatSettings?: IrisCourseChatSubSettings; irisLectureIngestionSettings?: IrisLectureIngestionSubSettings; irisCompetencyGenerationSettings?: IrisCompetencyGenerationSubSettings; + irisFaqIngestionSettings?: IrisFaqIngestionSubSettings; } export class IrisCourseSettings implements IrisSettings { @@ -42,6 +45,7 @@ export class IrisCourseSettings implements IrisSettings { irisCourseChatSettings?: IrisCourseChatSubSettings; irisLectureIngestionSettings?: IrisLectureIngestionSubSettings; irisCompetencyGenerationSettings?: IrisCompetencyGenerationSubSettings; + irisFaqIngestionSettings?: IrisFaqIngestionSubSettings; } export class IrisExerciseSettings implements IrisSettings { 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 1fc44fbf261d..f3c32d00304d 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 @@ -6,6 +6,7 @@ export enum IrisSubSettingsType { COURSE_CHAT = 'course-chat', LECTURE_INGESTION = 'lecture-ingestion', COMPETENCY_GENERATION = 'competency-generation', + FAQ_INGESTION = 'faq-ingestion', } export enum IrisEventType { @@ -46,6 +47,11 @@ export class IrisLectureIngestionSubSettings extends IrisSubSettings { autoIngestOnLectureAttachmentUpload: boolean; } +export class IrisFaqIngestionSubSettings extends IrisSubSettings { + type = IrisSubSettingsType.FAQ_INGESTION; + autoIngestOnFaqCreation: boolean; +} + export class IrisCompetencyGenerationSubSettings extends IrisSubSettings { type = IrisSubSettingsType.COMPETENCY_GENERATION; } diff --git a/src/main/webapp/app/faq/faq.component.html b/src/main/webapp/app/faq/faq.component.html index 5ec38d9468a0..7c8ab88ab683 100644 --- a/src/main/webapp/app/faq/faq.component.html +++ b/src/main/webapp/app/faq/faq.component.html @@ -40,11 +40,17 @@

} -
+
+ @if (isAtLeastInstructor && faqIngestionEnabled) { + + }
diff --git a/src/main/webapp/app/faq/faq.component.ts b/src/main/webapp/app/faq/faq.component.ts index 17d721b303c1..9d1f8f3592a2 100644 --- a/src/main/webapp/app/faq/faq.component.ts +++ b/src/main/webapp/app/faq/faq.component.ts @@ -1,6 +1,6 @@ import { Component, OnDestroy, OnInit, inject } from '@angular/core'; import { Faq, FaqState } from 'app/entities/faq.model'; -import { faCancel, faCheck, faEdit, faFilter, faPencilAlt, faPlus, faSort, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { faCancel, faCheck, faEdit, faFileExport, faFilter, faPencilAlt, faPlus, faSort, faTrash } from '@fortawesome/free-solid-svg-icons'; import { BehaviorSubject, Subject, Subscription } from 'rxjs'; import { debounceTime, map } from 'rxjs/operators'; import { AlertService } from 'app/core/util/alert.service'; @@ -18,6 +18,10 @@ import { ArtemisMarkdownModule } from 'app/shared/markdown.module'; import { SearchFilterComponent } from 'app/shared/search-filter/search-filter.component'; import { AccountService } from 'app/core/auth/account.service'; import { Course } from 'app/entities/course.model'; +import { PROFILE_IRIS } from 'app/app.constants'; +import { IrisSettingsService } from 'app/iris/settings/shared/iris-settings.service'; +import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; + import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; @Component({ @@ -35,6 +39,8 @@ export class FaqComponent implements OnInit, OnDestroy { courseId: number; hasCategories = false; isAtLeastInstructor = false; + faqIngestionEnabled = false; + irisEnabled = false; private dialogErrorSource = new Subject(); dialogError$ = this.dialogErrorSource.asObservable(); @@ -54,12 +60,22 @@ export class FaqComponent implements OnInit, OnDestroy { protected readonly faSort = faSort; protected readonly faCancel = faCancel; protected readonly faCheck = faCheck; + protected readonly faFileExport = faFileExport; private faqService = inject(FaqService); private route = inject(ActivatedRoute); private alertService = inject(AlertService); private sortService = inject(SortService); private accountService = inject(AccountService); + private profileService = inject(ProfileService); + private irisSettingsService = inject(IrisSettingsService); + + private profileInfoSubscription: Subscription; + + constructor() { + this.predicate = 'id'; + this.ascending = true; + } ngOnInit() { this.courseId = Number(this.route.snapshot.paramMap.get('courseId')); @@ -75,12 +91,21 @@ export class FaqComponent implements OnInit, OnDestroy { this.isAtLeastInstructor = this.accountService.isAtLeastInstructorInCourse(course); } }); + this.profileInfoSubscription = this.profileService.getProfileInfo().subscribe(async (profileInfo) => { + this.irisEnabled = profileInfo.activeProfiles.includes(PROFILE_IRIS); + if (this.irisEnabled) { + this.irisSettingsService.getCombinedCourseSettings(this.courseId).subscribe((settings) => { + this.faqIngestionEnabled = settings?.irisFaqIngestionSettings?.enabled || false; + }); + } + }); } ngOnDestroy(): void { this.dialogErrorSource.complete(); this.searchInput.complete(); this.routeDataSubscription?.unsubscribe(); + this.profileInfoSubscription?.unsubscribe(); } deleteFaq(courseId: number, faqId: number) { @@ -175,4 +200,15 @@ export class FaqComponent implements OnInit, OnDestroy { acceptProposedFaq(courseId: number, faq: Faq) { this.updateFaqState(courseId, faq, FaqState.ACCEPTED, 'artemisApp.faq.accepted'); } + + ingestFaqsInPyris() { + if (this.faqs.first()) { + this.faqService.ingestFaqsInPyris(this.courseId).subscribe({ + next: () => this.alertService.success('artemisApp.iris.ingestionAlert.allFaqsSuccess'), + error: () => { + this.alertService.error('artemisApp.iris.ingestionAlert.allFaqsError'); + }, + }); + } + } } diff --git a/src/main/webapp/app/faq/faq.service.ts b/src/main/webapp/app/faq/faq.service.ts index 2b8e8a5dd546..05cad6636f90 100644 --- a/src/main/webapp/app/faq/faq.service.ts +++ b/src/main/webapp/app/faq/faq.service.ts @@ -1,5 +1,5 @@ import { Injectable, inject } from '@angular/core'; -import { HttpClient, HttpResponse } from '@angular/common/http'; +import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { Faq, FaqState } from 'app/entities/faq.model'; @@ -159,4 +159,15 @@ export class FaqService { observe: 'response', }); } + + /** + * Trigger the Ingestion of all Faqs in the course. + */ + ingestFaqsInPyris(courseId: number): Observable> { + const params = new HttpParams(); + return this.http.post(`api/courses/${courseId}/faqs/ingest`, null, { + params: params, + observe: 'response', + }); + } } 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 f97278bc7a90..daf2995adb7f 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 @@ -72,6 +72,25 @@

+
+

+ +
+ @if (settingsType === COURSE) { +
+ + +
+ } +
} @if (settingsType !== EXERCISE) {
diff --git a/src/main/webapp/app/iris/settings/iris-settings-update/iris-settings-update.component.ts b/src/main/webapp/app/iris/settings/iris-settings-update/iris-settings-update.component.ts index 9cb22b67a5cc..4bd67c371d66 100644 --- a/src/main/webapp/app/iris/settings/iris-settings-update/iris-settings-update.component.ts +++ b/src/main/webapp/app/iris/settings/iris-settings-update/iris-settings-update.component.ts @@ -12,6 +12,7 @@ import { IrisChatSubSettings, IrisCompetencyGenerationSubSettings, IrisCourseChatSubSettings, + IrisFaqIngestionSubSettings, IrisLectureIngestionSubSettings, IrisTextExerciseChatSubSettings, } from 'app/entities/iris/settings/iris-sub-settings.model'; @@ -43,6 +44,7 @@ export class IrisSettingsUpdateComponent implements OnInit, DoCheck, ComponentCa originalIrisSettings?: IrisSettings; public autoLectureIngestion = false; + public autoFaqIngestion = false; // Status bools isLoading = false; @@ -95,6 +97,7 @@ export class IrisSettingsUpdateComponent implements OnInit, DoCheck, ComponentCa this.fillEmptyIrisSubSettings(); this.originalIrisSettings = cloneDeep(settings); this.autoLectureIngestion = this.irisSettings?.irisLectureIngestionSettings?.autoIngestOnLectureAttachmentUpload ?? false; + this.autoFaqIngestion = this.irisSettings?.irisFaqIngestionSettings?.autoIngestOnFaqCreation ?? false; this.isDirty = false; }); this.loadParentIrisSettingsObservable().subscribe((settings) => { @@ -124,6 +127,9 @@ export class IrisSettingsUpdateComponent implements OnInit, DoCheck, ComponentCa if (!this.irisSettings.irisCompetencyGenerationSettings) { this.irisSettings.irisCompetencyGenerationSettings = new IrisCompetencyGenerationSubSettings(); } + if (!this.irisSettings.irisFaqIngestionSettings) { + this.irisSettings.irisFaqIngestionSettings = new IrisFaqIngestionSubSettings(); + } } saveIrisSettings(): void { @@ -131,6 +137,9 @@ export class IrisSettingsUpdateComponent implements OnInit, DoCheck, ComponentCa if (this.irisSettings && this.irisSettings.irisLectureIngestionSettings) { this.irisSettings.irisLectureIngestionSettings.autoIngestOnLectureAttachmentUpload = this.autoLectureIngestion; } + if (this.irisSettings && this.irisSettings.irisFaqIngestionSettings) { + this.irisSettings.irisFaqIngestionSettings.autoIngestOnFaqCreation = this.autoFaqIngestion; + } this.saveIrisSettingsObservable().subscribe( (response) => { this.isSaving = false; diff --git a/src/main/webapp/i18n/de/faq.json b/src/main/webapp/i18n/de/faq.json index 76440a96998b..47bcbad36b3f 100644 --- a/src/main/webapp/i18n/de/faq.json +++ b/src/main/webapp/i18n/de/faq.json @@ -8,7 +8,8 @@ "accept": "FAQ akzeptieren", "reject": "FAQ ablehnen", "filterLabel": "Filter", - "createOrEditLabel": "FAQ erstellen oder bearbeiten" + "createOrEditLabel": "FAQ erstellen oder bearbeiten", + "ingestLabel": "FAQs an IRIS schicken" }, "created": "Die FAQ {{ title }} wurde erfolgreich erstellt", "updated": "Die FAQ {{ title }} wurde erfolgreich aktualisiert", diff --git a/src/main/webapp/i18n/de/iris.json b/src/main/webapp/i18n/de/iris.json index 2c01b1e3679d..19e77bf84ed4 100644 --- a/src/main/webapp/i18n/de/iris.json +++ b/src/main/webapp/i18n/de/iris.json @@ -31,6 +31,10 @@ "title": "Vorlesungen Erfassung Einstellungen", "autoIngestOnAttachmentUpload": "Vorlesungen automatisch an Pyris senden" }, + "faqIngestionSettings": { + "title": "FAQ Ingestion Settings", + "autoIngest": "FAQs automatisch an Pyris senden" + }, "competencyGenerationSettings": "Kompetenzgenerierung Einstellungen", "proactivityBuildFailedEventEnabled": { "label": "Build-Fehler überwachen", @@ -111,7 +115,9 @@ "lectureSuccess": "Vorlesung Ingestion in Pyris erfolgreich gestartet", "lectureError": "Fehler beim Senden der Vorlesung an Pyris", "allLecturesSuccess": "Alle Vorlesungen Ingestion in Pyris erfolgreich gestartet", + "allFaqsSuccess": "Alle Faqs Ingestion in Pyris erfolgreich gestartet", "allLecturesError": "Fehler beim Senden aller Vorlesungen an Pyris", + "allFaqsError": "Fehler beim Senden aller FAQs an Pyris", "lectureNotFound": "Vorlesung kann nicht an Pyris gesendet werden, keine Vorlesung mit der angegebenen ID gefunden.", "pyrisUnavailable": "Pyris ist derzeit nicht verfügbar.", "pyrisError": "Ein Fehler ist bei der Kommunikation mit Pyris aufgetreten." diff --git a/src/main/webapp/i18n/en/faq.json b/src/main/webapp/i18n/en/faq.json index 98f9fc345060..0ef576fcc937 100644 --- a/src/main/webapp/i18n/en/faq.json +++ b/src/main/webapp/i18n/en/faq.json @@ -8,7 +8,8 @@ "accept": "Accept FAQ", "reject": "Reject FAQ", "filterLabel": "Filter", - "createOrEditLabel": "Create or edit FAQ" + "createOrEditLabel": "Create or edit FAQ", + "ingestLabel": "Send FAQs to Iris" }, "created": "The FAQ {{ title }} was successfully created", "updated": "The FAQ {{ title }} was successfully updated", diff --git a/src/main/webapp/i18n/en/iris.json b/src/main/webapp/i18n/en/iris.json index fd12226bb148..6ac25267bd09 100644 --- a/src/main/webapp/i18n/en/iris.json +++ b/src/main/webapp/i18n/en/iris.json @@ -31,6 +31,10 @@ "title": "Lecture Ingestion Settings", "autoIngestOnAttachmentUpload": "Send Lectures To Pyris Automatically" }, + "faqIngestionSettings": { + "title": "FAQ Ingestion Settings", + "autoIngest": "Send FAQs To Pyris Automatically" + }, "competencyGenerationSettings": "Competency Generation Settings", "proactivityBuildFailedEventEnabled": { "label": "Monitor submission build failures", @@ -110,8 +114,10 @@ "lectureUnitError": "Error while sending lecture unit to Pyris", "lectureSuccess": "Lecture ingestion in Pyris started successfully", "lectureError": "Error while sending lecture to Pyris", - "allLecturesSuccess": "All lectures ingestion in Pyris started successfully", + "allLecturesSuccess": "All lecture ingestion in Pyris started successfully", + "allFaqsSuccess": "All Faq ingestion in Pyris started successfully", "allLecturesError": "Error while sending all lectures to Pyris", + "allFaqsError": "Error while sending all Faqs to Pyris", "lectureNotFound": "Could not send lecture to Iris, no lecture found with the provided id.", "pyrisUnavailable": "Pyris is currently unavailable.", "pyrisError": "An error occurred while getting ingestion state from Pyris." diff --git a/src/test/java/de/tum/cit/aet/artemis/communication/FaqIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/communication/FaqIntegrationTest.java index a7d071e75553..c0d714fb54e8 100644 --- a/src/test/java/de/tum/cit/aet/artemis/communication/FaqIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/communication/FaqIntegrationTest.java @@ -17,6 +17,7 @@ import de.tum.cit.aet.artemis.communication.domain.FaqState; import de.tum.cit.aet.artemis.communication.dto.FaqDTO; import de.tum.cit.aet.artemis.communication.repository.FaqRepository; +import de.tum.cit.aet.artemis.core.connector.IrisRequestMockProvider; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException; import de.tum.cit.aet.artemis.shared.base.AbstractSpringIntegrationIndependentTest; @@ -28,6 +29,9 @@ class FaqIntegrationTest extends AbstractSpringIntegrationIndependentTest { @Autowired private FaqRepository faqRepository; + @Autowired + private IrisRequestMockProvider irisRequestMockProvider; + private Course course1; private Course course2; @@ -46,6 +50,7 @@ void initTestCase() throws Exception { // Add users that are not in the course userUtilService.createAndSaveUser(TEST_PREFIX + "student42"); userUtilService.createAndSaveUser(TEST_PREFIX + "instructor42"); + irisRequestMockProvider.enableMockingOfRequests(); } @@ -164,6 +169,9 @@ void testGetFaqByFaqId_shouldNotGet_IdMismatch() throws Exception { @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void deleteFaq_shouldDeleteFAQ() throws Exception { Faq faq = faqRepository.findById(this.faq.getId()).orElseThrow(EntityNotFoundException::new); + irisRequestMockProvider.mockFaqDeletionWebhookRunResponse(dto -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + }); request.delete("/api/courses/" + faq.getCourse().getId() + "/faqs/" + faq.getId(), HttpStatus.OK); Optional faqOptional = faqRepository.findById(faq.getId()); assertThat(faqOptional).isEmpty(); 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 a71d07785f7b..3e6f17002d2c 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 @@ -36,6 +36,7 @@ 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; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.faqingestionwebhook.PyrisWebhookFaqIngestionExecutionDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.lectureingestionwebhook.PyrisWebhookLectureIngestionExecutionDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.rewriting.PyrisRewritingPipelineExecutionDTO; @@ -186,6 +187,15 @@ public void mockIngestionWebhookRunResponse(Consumer responseConsumer) { + mockServer.expect(ExpectedCount.once(), requestTo(webhooksApiURL + "/faqs")).andExpect(method(HttpMethod.POST)).andRespond(request -> { + var mockRequest = (MockClientHttpRequest) request; + var dto = mapper.readValue(mockRequest.getBodyAsString(), PyrisWebhookFaqIngestionExecutionDTO.class); + responseConsumer.accept(dto); + return MockRestResponseCreators.withRawStatus(HttpStatus.ACCEPTED.value()).createResponse(request); + }); + } + public void mockDeletionWebhookRunResponse(Consumer responseConsumer) { mockServer.expect(ExpectedCount.once(), requestTo(webhooksApiURL + "/lectures/delete")).andExpect(method(HttpMethod.POST)).andRespond(request -> { var mockRequest = (MockClientHttpRequest) request; @@ -195,6 +205,15 @@ public void mockDeletionWebhookRunResponse(Consumer responseConsumer) { + mockServer.expect(ExpectedCount.once(), requestTo(webhooksApiURL + "/faqs/delete")).andExpect(method(HttpMethod.POST)).andRespond(request -> { + var mockRequest = (MockClientHttpRequest) request; + var dto = mapper.readValue(mockRequest.getBodyAsString(), PyrisWebhookFaqIngestionExecutionDTO.class); + responseConsumer.accept(dto); + return MockRestResponseCreators.withRawStatus(HttpStatus.ACCEPTED.value()).createResponse(request); + }); + } + public void mockBuildFailedRunResponse(Consumer responseConsumer) { mockServer.expect(ExpectedCount.max(2), requestTo(pipelinesApiURL + "/tutor-chat/default/run?event=build_failed")).andExpect(method(HttpMethod.POST)) .andRespond(request -> { 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 b7866bd171b3..b378805763b9 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 @@ -63,6 +63,7 @@ protected void activateIrisGlobally() { activateSubSettings(globalSettings.getIrisCourseChatSettings()); activateSubSettings(globalSettings.getIrisLectureIngestionSettings()); activateSubSettings(globalSettings.getIrisCompetencyGenerationSettings()); + activateSubSettings(globalSettings.getIrisFaqIngestionSettings()); irisSettingsRepository.save(globalSettings); } @@ -85,6 +86,7 @@ protected void activateIrisFor(Course course) { activateSubSettings(courseSettings.getIrisCourseChatSettings()); activateSubSettings(courseSettings.getIrisCompetencyGenerationSettings()); activateSubSettings(courseSettings.getIrisLectureIngestionSettings()); + activateSubSettings(courseSettings.getIrisFaqIngestionSettings()); irisSettingsRepository.save(courseSettings); } diff --git a/src/test/java/de/tum/cit/aet/artemis/iris/PyrisFaqIngestionTest.java b/src/test/java/de/tum/cit/aet/artemis/iris/PyrisFaqIngestionTest.java new file mode 100644 index 000000000000..85b9e4e5e101 --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/iris/PyrisFaqIngestionTest.java @@ -0,0 +1,280 @@ +package de.tum.cit.aet.artemis.iris; + +import static de.tum.cit.aet.artemis.communication.FaqFactory.generateFaq; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.util.LinkedMultiValueMap; + +import de.tum.cit.aet.artemis.communication.FaqFactory; +import de.tum.cit.aet.artemis.communication.domain.Faq; +import de.tum.cit.aet.artemis.communication.domain.FaqState; +import de.tum.cit.aet.artemis.communication.repository.FaqRepository; +import de.tum.cit.aet.artemis.core.domain.Course; +import de.tum.cit.aet.artemis.core.user.util.UserUtilService; +import de.tum.cit.aet.artemis.core.util.CourseUtilService; +import de.tum.cit.aet.artemis.iris.domain.settings.IrisCourseSettings; +import de.tum.cit.aet.artemis.iris.repository.IrisSettingsRepository; +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.PyrisWebhookService; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.faqingestionwebhook.PyrisFaqIngestionStatusUpdateDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.status.PyrisStageDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.status.PyrisStageState; + +class PyrisFaqIngestionTest extends AbstractIrisIntegrationTest { + + private static final String TEST_PREFIX = "pyrisfaqingestiontest"; + + @Autowired + private PyrisWebhookService pyrisWebhookService; + + @Autowired + private FaqRepository faqRepository; + + @Autowired + private UserUtilService userUtilService; + + @Autowired + private CourseUtilService courseUtilService; + + @Autowired + protected PyrisStatusUpdateService pyrisStatusUpdateService; + + @Autowired + protected PyrisJobService pyrisJobService; + + @Autowired + protected IrisSettingsRepository irisSettingsRepository; + + private Faq faq1; + + private Course course1; + + private long courseId; + + @BeforeEach + void initTestCase() throws Exception { + userUtilService.addUsers(TEST_PREFIX, 1, 1, 0, 1); + List courses = courseUtilService.createCoursesWithExercisesAndLectures(TEST_PREFIX, true, true, 1); + this.course1 = this.courseRepository.findByIdWithExercisesAndExerciseDetailsAndLecturesElseThrow(courses.getFirst().getId()); + long courseId = course1.getId(); + this.faq1 = generateFaq(course1, FaqState.ACCEPTED, "Faq 1 title", "Faq 1 content"); + faqRepository.save(faq1); + // Add users that are not in the course + userUtilService.createAndSaveUser(TEST_PREFIX + "student42"); + userUtilService.createAndSaveUser(TEST_PREFIX + "tutor42"); + userUtilService.createAndSaveUser(TEST_PREFIX + "instructor42"); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void autoIngestionWhenFaqIsCreatedAndAutoUpdateEnabled() throws Exception { + activateIrisFor(faq1.getCourse()); + IrisCourseSettings courseSettings = irisSettingsService.getRawIrisSettingsFor(faq1.getCourse()); + courseSettings.getIrisFaqIngestionSettings().setAutoIngestOnFaqCreation(true); + this.irisSettingsRepository.save(courseSettings); + irisRequestMockProvider.mockFaqIngestionWebhookRunResponse(dto -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + }); + Faq newFaq = FaqFactory.generateFaq(course1, FaqState.ACCEPTED, "title", "answer"); + Faq returnedFaq = request.postWithResponseBody("/api/courses/" + course1.getId() + "/faqs", newFaq, Faq.class, HttpStatus.CREATED); + + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void noAutoIngestionWhenFaqIsCreatedAndAutoUpdateEnabled() throws Exception { + + irisRequestMockProvider.mockFaqIngestionWebhookRunResponse(dto -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + }); + Faq newFaq = FaqFactory.generateFaq(course1, FaqState.ACCEPTED, "title", "answer"); + Faq returnedFaq = request.postWithResponseBody("/api/courses/" + faq1.getCourse().getId() + "/faqs", newFaq, Faq.class, HttpStatus.CREATED); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testIngestFaqButtonInPyris() throws Exception { + activateIrisFor(faq1.getCourse()); + irisRequestMockProvider.mockFaqIngestionWebhookRunResponse(dto -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + }); + request.postWithResponseBody("/api/courses/" + faq1.getCourse().getId() + "/faqs/ingest", Optional.empty(), boolean.class, HttpStatus.OK); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testDeleteFaqFromPyrisDatabaseWithCourseSettingsEnabled() { + activateIrisFor(faq1.getCourse()); + IrisCourseSettings courseSettings = irisSettingsService.getRawIrisSettingsFor(faq1.getCourse()); + this.irisSettingsRepository.save(courseSettings); + irisRequestMockProvider.mockFaqDeletionWebhookRunResponse(dto -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + }); + String jobToken = pyrisWebhookService.deleteFaq(faq1); + assertThat(jobToken).isNotNull(); + + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testAddFaqToPyrisDBAddJobWithCourseSettingsEnabled() throws Exception { + activateIrisFor(faq1.getCourse()); + irisRequestMockProvider.mockFaqIngestionWebhookRunResponse(dto -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + }); + request.postWithResponseBody("/api/courses/" + faq1.getCourse().getId() + "/faqs/ingest", Optional.empty(), boolean.class, HttpStatus.OK); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testAddSpecificFaqToPyrisDBAddJobWithCourseSettingsEnabled() throws Exception { + activateIrisFor(faq1.getCourse()); + irisRequestMockProvider.mockFaqIngestionWebhookRunResponse(dto -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + }); + request.postWithResponseBody("/api/courses/" + faq1.getCourse().getId() + "/faqs/ingest?faqId=" + faq1.getId(), Optional.empty(), boolean.class, HttpStatus.OK); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testAllStagesDoneIngestionStateDone() throws Exception { + activateIrisFor(faq1.getCourse()); + irisRequestMockProvider.mockFaqIngestionWebhookRunResponse(dto -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + }); + String jobToken = pyrisWebhookService.addFaq(faq1); + PyrisStageDTO doneStage = new PyrisStageDTO("done", 1, PyrisStageState.DONE, "Stage completed successfully."); + PyrisFaqIngestionStatusUpdateDTO statusUpdate = new PyrisFaqIngestionStatusUpdateDTO("Success", List.of(doneStage), faq1.getId()); + var headers = new HttpHeaders(new LinkedMultiValueMap<>(Map.of("Authorization", List.of("Bearer " + jobToken)))); + request.postWithoutResponseBody("/api/public/pyris/webhooks/ingestion/faqs/runs/" + jobToken + "/status", statusUpdate, HttpStatus.OK, headers); + + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testAllStagesDoneRemovesDeletionIngestionJob() throws Exception { + activateIrisFor(faq1.getCourse()); + irisRequestMockProvider.mockFaqDeletionWebhookRunResponse(dto -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + }); + String jobToken = pyrisWebhookService.deleteFaq(faq1); + PyrisStageDTO doneStage = new PyrisStageDTO("done", 1, PyrisStageState.DONE, "Stage completed successfully."); + PyrisFaqIngestionStatusUpdateDTO statusUpdate = new PyrisFaqIngestionStatusUpdateDTO("Success", List.of(doneStage), faq1.getId()); + var headers = new HttpHeaders(new LinkedMultiValueMap<>(Map.of("Authorization", List.of("Bearer " + jobToken)))); + request.postWithoutResponseBody("/api/public/pyris/webhooks/ingestion/faqs/runs/" + jobToken + "/status", statusUpdate, HttpStatus.OK, headers); + assertThat(pyrisJobService.getJob(jobToken)).isNull(); + + } + + @Test + void testStageNotDoneKeepsAdditionIngestionJob() throws Exception { + activateIrisFor(faq1.getCourse()); + irisRequestMockProvider.mockFaqIngestionWebhookRunResponse(dto -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + }); + String jobToken = pyrisWebhookService.addFaq(faq1); + PyrisStageDTO doneStage = new PyrisStageDTO("done", 1, PyrisStageState.DONE, "Stage completed successfully."); + PyrisStageDTO inProgressStage = new PyrisStageDTO("inProgressStage", 1, PyrisStageState.IN_PROGRESS, "Stage completed successfully."); + PyrisFaqIngestionStatusUpdateDTO statusUpdate = new PyrisFaqIngestionStatusUpdateDTO("Success", List.of(doneStage, inProgressStage), faq1.getId()); + var headers = new HttpHeaders(new LinkedMultiValueMap<>(Map.of("Authorization", List.of("Bearer " + jobToken)))); + request.postWithoutResponseBody("/api/public/pyris/webhooks/ingestion/faqs/runs/" + jobToken + "/status", statusUpdate, HttpStatus.OK, headers); + assertThat(pyrisJobService.getJob(jobToken)).isNotNull(); + + } + + @Test + void testStageNotDoneKeepsDeletionIngestionJob() throws Exception { + activateIrisFor(faq1.getCourse()); + irisRequestMockProvider.mockFaqDeletionWebhookRunResponse(dto -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + }); + + String jobToken = pyrisWebhookService.deleteFaq(faq1); + PyrisStageDTO doneStage = new PyrisStageDTO("done", 1, PyrisStageState.DONE, "Stage completed successfully."); + PyrisStageDTO inProgressStage = new PyrisStageDTO("inProgressStage", 1, PyrisStageState.IN_PROGRESS, "Stage completed successfully."); + PyrisFaqIngestionStatusUpdateDTO statusUpdate = new PyrisFaqIngestionStatusUpdateDTO("Success", List.of(doneStage, inProgressStage), faq1.getId()); + var headers = new HttpHeaders(new LinkedMultiValueMap<>(Map.of("Authorization", List.of("Bearer " + jobToken)))); + request.postWithoutResponseBody("/api/public/pyris/webhooks/ingestion/faqs/runs/" + jobToken + "/status", statusUpdate, HttpStatus.OK, headers); + assertThat(pyrisJobService.getJob(jobToken)).isNotNull(); + + } + + @Test + void testErrorStageRemovesDeletionIngestionJob() throws Exception { + activateIrisFor(faq1.getCourse()); + irisRequestMockProvider.mockFaqDeletionWebhookRunResponse(dto -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + }); + + String jobToken = pyrisWebhookService.deleteFaq(faq1); + PyrisStageDTO errorStage = new PyrisStageDTO("error", 1, PyrisStageState.ERROR, "Stage not broke due to error."); + PyrisFaqIngestionStatusUpdateDTO statusUpdate = new PyrisFaqIngestionStatusUpdateDTO("Success", List.of(errorStage), faq1.getId()); + var headers = new HttpHeaders(new LinkedMultiValueMap<>(Map.of("Authorization", List.of("Bearer " + jobToken)))); + request.postWithoutResponseBody("/api/public/pyris/webhooks/ingestion/faqs/runs/" + jobToken + "/status", statusUpdate, HttpStatus.OK, headers); + assertThat(pyrisJobService.getJob(jobToken)).isNull(); + + } + + @Test + void testErrorStageRemovesAdditionIngestionJob() throws Exception { + activateIrisFor(faq1.getCourse()); + irisRequestMockProvider.mockFaqIngestionWebhookRunResponse(dto -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + }); + String jobToken = pyrisWebhookService.addFaq(faq1); + PyrisStageDTO errorStage = new PyrisStageDTO("error", 1, PyrisStageState.ERROR, "Stage not broke due to error."); + PyrisFaqIngestionStatusUpdateDTO statusUpdate = new PyrisFaqIngestionStatusUpdateDTO("Success", List.of(errorStage), faq1.getId()); + var headers = new HttpHeaders(new LinkedMultiValueMap<>(Map.of("Authorization", List.of("Bearer " + jobToken)))); + request.postWithoutResponseBody("/api/public/pyris/webhooks/ingestion/faqs/runs/" + jobToken + "/status", statusUpdate, HttpStatus.OK, headers); + assertThat(pyrisJobService.getJob(jobToken)).isNull(); + + } + + @Test + void testRunIdIngestionJob() throws Exception { + activateIrisFor(faq1.getCourse()); + irisRequestMockProvider.mockFaqIngestionWebhookRunResponse(dto -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + }); + String newJobToken = pyrisJobService.addFaqIngestionWebhookJob(123L, faq1.getId()); + String chatJobToken = pyrisJobService.addCourseChatJob(123L, 123L); + PyrisStageDTO errorStage = new PyrisStageDTO("error", 1, PyrisStageState.ERROR, "Stage not broke due to error."); + PyrisFaqIngestionStatusUpdateDTO statusUpdate = new PyrisFaqIngestionStatusUpdateDTO("Success", List.of(errorStage), faq1.getId()); + var headers = new HttpHeaders(new LinkedMultiValueMap<>(Map.of("Authorization", List.of("Bearer " + chatJobToken)))); + MockHttpServletResponse response = request.postWithoutResponseBody("/api/public/pyris/webhooks/ingestion/runs/" + newJobToken + "/status", statusUpdate, + HttpStatus.CONFLICT, headers); + assertThat(response.getContentAsString()).contains("Run ID in URL does not match run ID in request body"); + response = request.postWithoutResponseBody("/api/public/pyris/webhooks/ingestion/faqs/runs/" + chatJobToken + "/status", statusUpdate, HttpStatus.CONFLICT, headers); + assertThat(response.getContentAsString()).contains("Run ID is not an ingestion job"); + } + + @Test + void testIngestionJobDone() throws Exception { + activateIrisFor(faq1.getCourse()); + irisRequestMockProvider.mockFaqIngestionWebhookRunResponse(dto -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + }); + String newJobToken = pyrisJobService.addFaqIngestionWebhookJob(123L, faq1.getId()); + String chatJobToken = pyrisJobService.addCourseChatJob(123L, 123L); + PyrisStageDTO errorStage = new PyrisStageDTO("error", 1, PyrisStageState.ERROR, "Stage not broke due to error."); + PyrisFaqIngestionStatusUpdateDTO statusUpdate = new PyrisFaqIngestionStatusUpdateDTO("Success", List.of(errorStage), faq1.getId()); + var headers = new HttpHeaders(new LinkedMultiValueMap<>(Map.of("Authorization", List.of("Bearer " + chatJobToken)))); + MockHttpServletResponse response = request.postWithoutResponseBody("/api/public/pyris/webhooks/ingestion/runs/" + newJobToken + "/status", statusUpdate, + HttpStatus.CONFLICT, headers); + assertThat(response.getContentAsString()).contains("Run ID in URL does not match run ID in request body"); + response = request.postWithoutResponseBody("/api/public/pyris/webhooks/ingestion/faqs/runs/" + chatJobToken + "/status", statusUpdate, HttpStatus.CONFLICT, headers); + assertThat(response.getContentAsString()).contains("Run ID is not an ingestion job"); + } +} diff --git a/src/test/java/de/tum/cit/aet/artemis/iris/PyrisLectureIngestionTest.java b/src/test/java/de/tum/cit/aet/artemis/iris/PyrisLectureIngestionTest.java index 636d18003de1..95715a5fdff7 100644 --- a/src/test/java/de/tum/cit/aet/artemis/iris/PyrisLectureIngestionTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/iris/PyrisLectureIngestionTest.java @@ -277,7 +277,7 @@ void testRunIdIngestionJob() throws Exception { irisRequestMockProvider.mockIngestionWebhookRunResponse(dto -> { assertThat(dto.settings().authenticationToken()).isNotNull(); }); - String newJobToken = pyrisJobService.addIngestionWebhookJob(123L, lecture1.getId(), lecture1.getLectureUnits().getFirst().getId()); + String newJobToken = pyrisJobService.addLectureIngestionWebhookJob(123L, lecture1.getId(), lecture1.getLectureUnits().getFirst().getId()); String chatJobToken = pyrisJobService.addCourseChatJob(123L, 123L); PyrisStageDTO errorStage = new PyrisStageDTO("error", 1, PyrisStageState.ERROR, "Stage not broke due to error."); PyrisLectureIngestionStatusUpdateDTO statusUpdate = new PyrisLectureIngestionStatusUpdateDTO("Success", List.of(errorStage), lecture1.getLectureUnits().getFirst().getId()); @@ -295,7 +295,7 @@ void testIngestionJobDone() throws Exception { irisRequestMockProvider.mockIngestionWebhookRunResponse(dto -> { assertThat(dto.settings().authenticationToken()).isNotNull(); }); - String newJobToken = pyrisJobService.addIngestionWebhookJob(123L, lecture1.getId(), lecture1.getLectureUnits().getFirst().getId()); + String newJobToken = pyrisJobService.addLectureIngestionWebhookJob(123L, lecture1.getId(), lecture1.getLectureUnits().getFirst().getId()); String chatJobToken = pyrisJobService.addCourseChatJob(123L, 123L); PyrisStageDTO errorStage = new PyrisStageDTO("error", 1, PyrisStageState.ERROR, "Stage not broke due to error."); PyrisLectureIngestionStatusUpdateDTO statusUpdate = new PyrisLectureIngestionStatusUpdateDTO("Success", List.of(errorStage), lecture1.getLectureUnits().getFirst().getId()); diff --git a/src/test/javascript/spec/component/account/account-information.component.spec.ts b/src/test/javascript/spec/component/account/account-information.component.spec.ts index df1f4575d02c..855151d1e99e 100644 --- a/src/test/javascript/spec/component/account/account-information.component.spec.ts +++ b/src/test/javascript/spec/component/account/account-information.component.spec.ts @@ -1,55 +1,126 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { AccountInformationComponent } from 'app/shared/user-settings/account-information/account-information.component'; import { AccountService } from 'app/core/auth/account.service'; -import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; -import { of } from 'rxjs'; +import { UserSettingsService } from 'app/shared/user-settings/user-settings.service'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { of, throwError } from 'rxjs'; import { User } from 'app/core/user/user.model'; import { ArtemisTestModule } from '../../test.module'; -import { MockNgbModalService } from '../../helpers/mocks/service/mock-ngb-modal.service'; -import { MockTranslateService } from '../../helpers/mocks/service/mock-translate.service'; -import { TranslateService } from '@ngx-translate/core'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { PROFILE_LOCALVC } from 'app/app.constants'; +import { HttpResponse, HttpErrorResponse } from '@angular/common/http'; +import { AlertService } from 'app/core/util/alert.service'; describe('AccountInformationComponent', () => { let fixture: ComponentFixture; let comp: AccountInformationComponent; - let accountServiceMock: { getAuthenticationState: jest.Mock; addSshPublicKey: jest.Mock }; - let profileServiceMock: { getProfileInfo: jest.Mock }; - let translateService: TranslateService; + let accountServiceMock: { getAuthenticationState: jest.Mock }; + let userSettingsServiceMock: { updateProfilePicture: jest.Mock; removeProfilePicture: jest.Mock }; + let modalServiceMock: { open: jest.Mock }; + let alertServiceMock: { addAlert: jest.Mock }; beforeEach(async () => { - profileServiceMock = { - getProfileInfo: jest.fn(), - }; accountServiceMock = { getAuthenticationState: jest.fn(), - addSshPublicKey: jest.fn(), + }; + userSettingsServiceMock = { + updateProfilePicture: jest.fn(), + removeProfilePicture: jest.fn(), + }; + modalServiceMock = { + open: jest.fn(), + }; + alertServiceMock = { + addAlert: jest.fn(), }; await TestBed.configureTestingModule({ imports: [ArtemisTestModule], providers: [ { provide: AccountService, useValue: accountServiceMock }, - { provide: ProfileService, useValue: profileServiceMock }, - { provide: TranslateService, useClass: MockTranslateService }, - { provide: NgbModal, useClass: MockNgbModalService }, + { provide: UserSettingsService, useValue: userSettingsServiceMock }, + { provide: NgbModal, useValue: modalServiceMock }, + { provide: AlertService, useValue: alertServiceMock }, ], }).compileComponents(); fixture = TestBed.createComponent(AccountInformationComponent); comp = fixture.componentInstance; - translateService = TestBed.inject(TranslateService); - translateService.currentLang = 'en'; }); beforeEach(() => { - profileServiceMock.getProfileInfo.mockReturnValue(of({ activeProfiles: [PROFILE_LOCALVC] })); accountServiceMock.getAuthenticationState.mockReturnValue(of({ id: 99 } as User)); + comp.ngOnInit(); }); - it('should initialize with localVC profile', async () => { - comp.ngOnInit(); + it('should initialize and fetch current user', () => { expect(accountServiceMock.getAuthenticationState).toHaveBeenCalled(); + expect(comp.currentUser).toEqual({ id: 99 }); + }); + + it('should open image cropper modal when setting user image', () => { + const event = { currentTarget: { files: [new File([''], 'test.jpg', { type: 'image/jpeg' })] } } as unknown as Event; + modalServiceMock.open.mockReturnValue({ componentInstance: {}, result: Promise.resolve('data:image/jpeg;base64,test') }); + + comp.setUserImage(event); + + expect(modalServiceMock.open).toHaveBeenCalled(); + }); + + it('should call removeProfilePicture when deleting user image', () => { + userSettingsServiceMock.removeProfilePicture.mockReturnValue(of(new HttpResponse({ status: 200 }))); + + comp.deleteUserImage(); + + expect(userSettingsServiceMock.removeProfilePicture).toHaveBeenCalled(); + }); + + it('should update user image on successful upload', () => { + const userResponse = new HttpResponse({ + body: { + imageUrl: 'new-image-url', + internal: false, + }, + }); + userSettingsServiceMock.updateProfilePicture.mockReturnValue(of(userResponse)); + + comp['subscribeToUpdateProfilePictureResponse'](userSettingsServiceMock.updateProfilePicture()); + + expect(comp.currentUser!.imageUrl).toBe('new-image-url'); + }); + + it('should show error alert when image upload fails', () => { + const errorResponse = new HttpErrorResponse({ error: { title: 'Upload failed' }, status: 400 }); + userSettingsServiceMock.updateProfilePicture.mockReturnValue(throwError(() => errorResponse)); + + comp['subscribeToUpdateProfilePictureResponse'](userSettingsServiceMock.updateProfilePicture()); + + expect(alertServiceMock.addAlert).toHaveBeenCalledWith(expect.objectContaining({ message: 'Upload failed' })); + }); + + it('should show error alert when profile picture removal fails', () => { + const errorResponse = new HttpErrorResponse({ error: { title: 'Removal failed' }, status: 400 }); + + comp['onProfilePictureRemoveError'](errorResponse); + + expect(alertServiceMock.addAlert).toHaveBeenCalledWith( + expect.objectContaining({ + type: expect.anything(), + message: 'Removal failed', + disableTranslation: true, + }), + ); + }); + + it('should show error alert when profile picture upload fails', () => { + const errorResponse = new HttpErrorResponse({ error: { title: 'Upload failed' }, status: 400 }); + + comp['onProfilePictureUploadError'](errorResponse); + + expect(alertServiceMock.addAlert).toHaveBeenCalledWith( + expect.objectContaining({ + type: expect.anything(), + message: 'Upload failed', + disableTranslation: true, + }), + ); }); }); diff --git a/src/test/javascript/spec/component/faq/faq.component.spec.ts b/src/test/javascript/spec/component/faq/faq.component.spec.ts index 2bd3be3da311..f345c9ff6650 100644 --- a/src/test/javascript/spec/component/faq/faq.component.spec.ts +++ b/src/test/javascript/spec/component/faq/faq.component.spec.ts @@ -20,6 +20,12 @@ import { AlertService } from 'app/core/util/alert.service'; import { SortService } from 'app/shared/service/sort.service'; import { MockAccountService } from '../../helpers/mocks/service/mock-account.service'; import { AccountService } from 'app/core/auth/account.service'; +import { IrisSettingsService } from 'app/iris/settings/shared/iris-settings.service'; +import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; +import { PROFILE_IRIS } from 'app/app.constants'; +import { ProfileInfo } from 'app/shared/layouts/profiles/profile-info.model'; +import { IrisCourseSettings } from 'app/entities/iris/settings/iris-settings.model'; +import { MockProfileService } from '../../helpers/mocks/service/mock-profile.service'; import 'jest-extended'; function createFaq(id: number, category: string, color: string): Faq { @@ -40,6 +46,8 @@ describe('FaqComponent', () => { let alertServiceStub: jest.SpyInstance; let alertService: AlertService; let sortService: SortService; + let profileService: ProfileService; + let irisSettingsService: IrisSettingsService; let faq1: Faq; let faq2: Faq; @@ -55,6 +63,10 @@ describe('FaqComponent', () => { courseId = 1; + const profileInfo = { + activeProfiles: [], + } as unknown as ProfileInfo; + TestBed.configureTestingModule({ imports: [ArtemisTestModule, MockModule(ArtemisMarkdownEditorModule), MockModule(BrowserAnimationsModule)], declarations: [FaqComponent, MockRouterLinkDirective, MockComponent(CustomExerciseCategoryBadgeComponent)], @@ -73,6 +85,7 @@ describe('FaqComponent', () => { }, }, }, + { provide: ProfileService, useValue: new MockProfileService() }, MockProvider(FaqService, { findAllByCourseId: () => { return of( @@ -110,6 +123,12 @@ describe('FaqComponent', () => { faqService = TestBed.inject(FaqService); alertService = TestBed.inject(AlertService); sortService = TestBed.inject(SortService); + + profileService = TestBed.inject(ProfileService); + irisSettingsService = TestBed.inject(IrisSettingsService); + + profileService = faqComponentFixture.debugElement.injector.get(ProfileService); + jest.spyOn(profileService, 'getProfileInfo').mockReturnValue(of(profileInfo)); }); }); @@ -225,4 +244,37 @@ describe('FaqComponent', () => { expect(faqService.update).toHaveBeenCalledExactlyOnceWith(courseId, faq1); expect(faq1.faqState).toEqual(FaqState.PROPOSED); }); + + it('should call the service to ingest faqs when ingestFaqsInPyris is called', () => { + faqComponent.faqs = [faq1]; + const ingestSpy = jest.spyOn(faqService, 'ingestFaqsInPyris').mockImplementation(() => of(new HttpResponse({ status: 200 }))); + faqComponent.ingestFaqsInPyris(); + expect(ingestSpy).toHaveBeenCalledWith(faq1.course?.id); + expect(ingestSpy).toHaveBeenCalledOnce(); + }); + + it('should log error when error occurs', () => { + alertServiceStub = jest.spyOn(alertService, 'error'); + faqComponent.faqs = [faq1]; + jest.spyOn(faqService, 'ingestFaqsInPyris').mockReturnValue(throwError(() => new Error('Error while ingesting'))); + faqComponent.ingestFaqsInPyris(); + expect(alertServiceStub).toHaveBeenCalledOnce(); + }); + + it('should set faqIngestionEnabled based on service response', () => { + faqComponent.faqs = [faq1]; + const profileInfoResponse = { + activeProfiles: [PROFILE_IRIS], + } as ProfileInfo; + const irisSettingsResponse = { + irisFaqIngestionSettings: { + enabled: true, + }, + } as IrisCourseSettings; + jest.spyOn(profileService, 'getProfileInfo').mockReturnValue(of(profileInfoResponse)); + jest.spyOn(irisSettingsService, 'getCombinedCourseSettings').mockImplementation(() => of(irisSettingsResponse)); + faqComponent.ngOnInit(); + expect(irisSettingsService.getCombinedCourseSettings).toHaveBeenCalledWith(faqComponent.courseId); + expect(faqComponent.faqIngestionEnabled).toBeTrue(); + }); }); 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 0b81f07452e8..aecfbc6d167e 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 @@ -62,7 +62,7 @@ describe('IrisCourseSettingsUpdateComponent Component', () => { expect(getSettingsSpy).toHaveBeenCalledWith(1); expect(getParentSettingsSpy).toHaveBeenCalledOnce(); - expect(fixture.debugElement.queryAll(By.directive(IrisCommonSubSettingsUpdateComponent))).toHaveLength(5); + expect(fixture.debugElement.queryAll(By.directive(IrisCommonSubSettingsUpdateComponent))).toHaveLength(6); }); it('Can deactivate correctly', () => { @@ -95,5 +95,6 @@ describe('IrisCourseSettingsUpdateComponent Component', () => { expect(comp.settingsUpdateComponent!.irisSettings.irisCourseChatSettings).toBeTruthy(); expect(comp.settingsUpdateComponent!.irisSettings.irisLectureIngestionSettings).toBeTruthy(); expect(comp.settingsUpdateComponent!.irisSettings.irisCompetencyGenerationSettings).toBeTruthy(); + expect(comp.settingsUpdateComponent!.irisSettings.irisFaqIngestionSettings).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 b9bf9836cbbe..e9f260992f55 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 @@ -49,7 +49,7 @@ describe('IrisGlobalSettingsUpdateComponent Component', () => { expect(comp.settingsUpdateComponent).toBeTruthy(); expect(getSettingsSpy).toHaveBeenCalledOnce(); - expect(fixture.debugElement.queryAll(By.directive(IrisCommonSubSettingsUpdateComponent))).toHaveLength(5); + expect(fixture.debugElement.queryAll(By.directive(IrisCommonSubSettingsUpdateComponent))).toHaveLength(6); }); it('Can deactivate correctly', () => { diff --git a/src/test/javascript/spec/component/lecture/lecture-detail.component.spec.ts b/src/test/javascript/spec/component/lecture/lecture-detail.component.spec.ts index d6718670380e..24931c0ee096 100644 --- a/src/test/javascript/spec/component/lecture/lecture-detail.component.spec.ts +++ b/src/test/javascript/spec/component/lecture/lecture-detail.component.spec.ts @@ -104,6 +104,7 @@ describe('LectureDetailComponent', () => { expect(ingestSpy).toHaveBeenCalledWith(mockLecture.course?.id, mockLecture.id); expect(ingestSpy).toHaveBeenCalledOnce(); }); + it('should log error when error occurs', () => { component.lecture = mockLecture; jest.spyOn(lectureService, 'ingestLecturesInPyris').mockReturnValue(throwError(() => new Error('Error while ingesting'))); diff --git a/src/test/javascript/spec/service/faq.service.spec.ts b/src/test/javascript/spec/service/faq.service.spec.ts index 73240db5f74d..a0b70974320b 100644 --- a/src/test/javascript/spec/service/faq.service.spec.ts +++ b/src/test/javascript/spec/service/faq.service.spec.ts @@ -257,5 +257,21 @@ describe('Faq Service', () => { expect(service.hasSearchTokens(faq1, 'title answer')).toBeTrue(); expect(service.hasSearchTokens(faq1, 'title answer missing')).toBeFalse(); }); + + it('should send a POST request to ingest faqs and return an OK response', () => { + const courseId = 123; + const expectedUrl = `api/courses/${courseId}/faqs/ingest`; + const expectedStatus = 200; + + service.ingestFaqsInPyris(courseId).subscribe((response) => { + expect(response.status).toBe(expectedStatus); + }); + + const req = httpMock.expectOne({ + url: expectedUrl, + method: 'POST', + }); + expect(req.request.method).toBe('POST'); + }); }); });