diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/IrisRewritingService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/IrisRewritingService.java new file mode 100644 index 000000000000..36432e696de9 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/IrisRewritingService.java @@ -0,0 +1,97 @@ +package de.tum.cit.aet.artemis.iris.service; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_IRIS; + +import java.util.Optional; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import de.tum.cit.aet.artemis.core.domain.Course; +import de.tum.cit.aet.artemis.core.domain.LLMServiceType; +import de.tum.cit.aet.artemis.core.domain.User; +import de.tum.cit.aet.artemis.core.repository.CourseRepository; +import de.tum.cit.aet.artemis.core.repository.UserRepository; +import de.tum.cit.aet.artemis.core.service.LLMTokenUsageService; +import de.tum.cit.aet.artemis.iris.service.pyris.PyrisJobService; +import de.tum.cit.aet.artemis.iris.service.pyris.PyrisPipelineService; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.rewriting.PyrisRewritingPipelineExecutionDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.rewriting.PyrisRewritingStatusUpdateDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.rewriting.RewritingVariant; +import de.tum.cit.aet.artemis.iris.service.pyris.job.RewritingJob; +import de.tum.cit.aet.artemis.iris.service.websocket.IrisWebsocketService; + +/** + * Service to handle the rewriting subsystem of Iris. + */ +@Service +@Profile(PROFILE_IRIS) +public class IrisRewritingService { + + private final PyrisPipelineService pyrisPipelineService; + + private final LLMTokenUsageService llmTokenUsageService; + + private final CourseRepository courseRepository; + + private final IrisWebsocketService websocketService; + + private final PyrisJobService pyrisJobService; + + private final UserRepository userRepository; + + public IrisRewritingService(PyrisPipelineService pyrisPipelineService, LLMTokenUsageService llmTokenUsageService, CourseRepository courseRepository, + IrisWebsocketService websocketService, PyrisJobService pyrisJobService, UserRepository userRepository) { + this.pyrisPipelineService = pyrisPipelineService; + this.llmTokenUsageService = llmTokenUsageService; + this.courseRepository = courseRepository; + this.websocketService = websocketService; + this.pyrisJobService = pyrisJobService; + this.userRepository = userRepository; + } + + /** + * Executes the rewriting pipeline on Pyris + * + * @param user the user for which the pipeline should be executed + * @param course the course for which the pipeline should be executed + * @param variant the rewriting variant to be used + * @param toBeRewritten the text to be rewritten + */ + public void executeRewritingPipeline(User user, Course course, RewritingVariant variant, String toBeRewritten) { + // @formatter:off + pyrisPipelineService.executePipeline( + "rewriting", + variant.toString(), + Optional.empty(), + pyrisJobService.createTokenForJob(token -> new RewritingJob(token, course.getId(), user.getId())), + executionDto -> new PyrisRewritingPipelineExecutionDTO(executionDto, toBeRewritten), + stages -> websocketService.send(user.getLogin(), websocketTopic(course.getId()), new PyrisRewritingStatusUpdateDTO(stages, null, null)) + ); + // @formatter:on + } + + /** + * Takes a status update from Pyris containing a new rewriting result and sends it to the client via websocket + * + * @param job Job related to the status update + * @param statusUpdate the status update containing text recommendations + * @return the same job that was passed in + */ + public RewritingJob handleStatusUpdate(RewritingJob job, PyrisRewritingStatusUpdateDTO statusUpdate) { + Course course = courseRepository.findByIdForUpdateElseThrow(job.courseId()); + if (statusUpdate.tokens() != null && !statusUpdate.tokens().isEmpty()) { + llmTokenUsageService.saveLLMTokenUsage(statusUpdate.tokens(), LLMServiceType.IRIS, builder -> builder.withCourse(course.getId()).withUser(job.userId())); + } + + var user = userRepository.findById(job.userId()).orElseThrow(); + websocketService.send(user.getLogin(), websocketTopic(job.courseId()), statusUpdate); + + return job; + } + + private static String websocketTopic(long courseId) { + return "rewriting/" + courseId; + } + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisDTOService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisDTOService.java index ca42f85363bc..229e9cf1da5d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisDTOService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisDTOService.java @@ -59,6 +59,10 @@ public PyrisDTOService(GitService gitService, RepositoryService repositoryServic public PyrisProgrammingExerciseDTO toPyrisProgrammingExerciseDTO(ProgrammingExercise exercise) { var templateRepositoryContents = getFilteredRepositoryContents(exercise.getTemplateParticipation()); var solutionRepositoryContents = getFilteredRepositoryContents(exercise.getSolutionParticipation()); + + // var templateRepositoryContents = new HashMap(); + // var solutionRepositoryContents = new HashMap(); + Optional testRepo = Optional.empty(); try { testRepo = Optional.ofNullable(gitService.getOrCheckoutRepository(exercise.getVcsTestRepositoryUri(), true)); 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 ad1a63e60c58..258be6263b7e 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 @@ -10,10 +10,12 @@ import org.springframework.stereotype.Service; import de.tum.cit.aet.artemis.iris.service.IrisCompetencyGenerationService; +import de.tum.cit.aet.artemis.iris.service.IrisRewritingService; import de.tum.cit.aet.artemis.iris.service.pyris.dto.chat.PyrisChatStatusUpdateDTO; 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.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; import de.tum.cit.aet.artemis.iris.service.pyris.dto.status.PyrisStageState; import de.tum.cit.aet.artemis.iris.service.pyris.job.CompetencyExtractionJob; @@ -21,6 +23,7 @@ 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.PyrisJob; +import de.tum.cit.aet.artemis.iris.service.pyris.job.RewritingJob; import de.tum.cit.aet.artemis.iris.service.pyris.job.TextExerciseChatJob; import de.tum.cit.aet.artemis.iris.service.pyris.job.TrackedSessionBasedPyrisJob; import de.tum.cit.aet.artemis.iris.service.session.IrisCourseChatSessionService; @@ -41,16 +44,19 @@ public class PyrisStatusUpdateService { private final IrisCompetencyGenerationService competencyGenerationService; + private final IrisRewritingService rewritingService; + private static final Logger log = LoggerFactory.getLogger(PyrisStatusUpdateService.class); public PyrisStatusUpdateService(PyrisJobService pyrisJobService, IrisExerciseChatSessionService irisExerciseChatSessionService, IrisTextExerciseChatSessionService irisTextExerciseChatSessionService, IrisCourseChatSessionService courseChatSessionService, - IrisCompetencyGenerationService competencyGenerationService) { + IrisCompetencyGenerationService competencyGenerationService, IrisRewritingService rewritingService) { this.pyrisJobService = pyrisJobService; this.irisExerciseChatSessionService = irisExerciseChatSessionService; this.irisTextExerciseChatSessionService = irisTextExerciseChatSessionService; this.courseChatSessionService = courseChatSessionService; this.competencyGenerationService = competencyGenerationService; + this.rewritingService = rewritingService; } /** @@ -105,6 +111,18 @@ public void handleStatusUpdate(CompetencyExtractionJob job, PyrisCompetencyStatu removeJobIfTerminatedElseUpdate(statusUpdate.stages(), updatedJob); } + /** + * Handles the status update of a rewriting job and forwards it to + * {@link IrisRewritingService#handleStatusUpdate(RewritingJob, PyrisRewritingStatusUpdateDTO)} + * + * @param job the job that is updated + * @param statusUpdate the status update + */ + public void handleStatusUpdate(RewritingJob job, PyrisRewritingStatusUpdateDTO statusUpdate) { + var updatedJob = rewritingService.handleStatusUpdate(job, statusUpdate); + removeJobIfTerminatedElseUpdate(statusUpdate.stages(), updatedJob); + } + /** * Removes the job from the job service if the status update indicates that the job is terminated; updates it to distribute changes otherwise. * A job is terminated if all stages are in a terminal state. diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/competency/PyrisCompetencyExtractionPipelineExecutionDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/competency/PyrisCompetencyExtractionPipelineExecutionDTO.java index 22fcaa3c1f9a..f0b401145bfd 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/competency/PyrisCompetencyExtractionPipelineExecutionDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/competency/PyrisCompetencyExtractionPipelineExecutionDTO.java @@ -6,7 +6,7 @@ import de.tum.cit.aet.artemis.iris.service.pyris.dto.PyrisPipelineExecutionDTO; /** - * DTO to execute the Iris competency extraction pipeline on Pyris + * DTO to execute the Iris rewriting pipeline on Pyris * * @param execution The pipeline execution details * @param courseDescription The description of the course diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/data/PyrisRewriteTextRequestDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/data/PyrisRewriteTextRequestDTO.java new file mode 100644 index 000000000000..1e7cd96bcaa5 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/data/PyrisRewriteTextRequestDTO.java @@ -0,0 +1,9 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.dto.data; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.iris.service.pyris.dto.rewriting.RewritingVariant; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record PyrisRewriteTextRequestDTO(String toBeRewritten, RewritingVariant variant) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/rewriting/PyrisRewritingPipelineExecutionDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/rewriting/PyrisRewritingPipelineExecutionDTO.java new file mode 100644 index 000000000000..6aae602ab5fe --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/rewriting/PyrisRewritingPipelineExecutionDTO.java @@ -0,0 +1,15 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.dto.rewriting; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.iris.service.pyris.dto.PyrisPipelineExecutionDTO; + +/** + * DTO to execute the Iris rewriting pipeline on Pyris + * + * @param execution The pipeline execution details + * @param toBeRewritten The text to be rewritten + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record PyrisRewritingPipelineExecutionDTO(PyrisPipelineExecutionDTO execution, String toBeRewritten) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/rewriting/PyrisRewritingStatusUpdateDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/rewriting/PyrisRewritingStatusUpdateDTO.java new file mode 100644 index 000000000000..084b65417cd3 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/rewriting/PyrisRewritingStatusUpdateDTO.java @@ -0,0 +1,21 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.dto.rewriting; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.core.domain.LLMRequest; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.status.PyrisStageDTO; + +/** + * DTO for the Iris rewriting feature. + * Pyris sends callback updates back to Artemis during rewriting of the text. These updates contain the current status of the rewriting process, + * which are then forwarded to the user via Websockets. + * + * @param stages List of stages of the generation process + * @param result The result of the rewriting process so far + * @param tokens List of token usages send by Pyris for tracking the token usage and cost + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record PyrisRewritingStatusUpdateDTO(List stages, String result, List tokens) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/rewriting/RewritingVariant.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/rewriting/RewritingVariant.java new file mode 100644 index 000000000000..aeaaf20db46a --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/rewriting/RewritingVariant.java @@ -0,0 +1,5 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.dto.rewriting; + +public enum RewritingVariant { + FAQ, PROBLEM_STATEMENT +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/RewritingJob.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/RewritingJob.java new file mode 100644 index 000000000000..37016865cb8c --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/RewritingJob.java @@ -0,0 +1,22 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.job; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.core.domain.Course; + +/** + * A Pyris job to rewrite a text. + * + * @param jobId the job id + * @param courseId the course in which the rewriting is being done + * @param userId the user who started the job + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record RewritingJob(String jobId, long courseId, long userId) implements PyrisJob { + + @Override + public boolean canAccess(Course course) { + return course.getId().equals(courseId); + } + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisRewritingResource.java b/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisRewritingResource.java new file mode 100644 index 000000000000..b3a437d964c6 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisRewritingResource.java @@ -0,0 +1,66 @@ +package de.tum.cit.aet.artemis.iris.web; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_IRIS; + +import java.util.Optional; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import de.tum.cit.aet.artemis.core.repository.CourseRepository; +import de.tum.cit.aet.artemis.core.repository.UserRepository; +import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInCourse.EnforceAtLeastTutorInCourse; +import de.tum.cit.aet.artemis.iris.service.IrisRewritingService; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.data.PyrisRewriteTextRequestDTO; + +/** + * REST controller for managing Markdown Rewritings. + */ +@Profile(PROFILE_IRIS) +@RestController +@RequestMapping("api/") +public class IrisRewritingResource { + + private static final Logger log = LoggerFactory.getLogger(IrisRewritingResource.class); + + private static final String ENTITY_NAME = "rewriting"; + + private final UserRepository userRepository; + + private final CourseRepository courseRepository; + + private final Optional irisRewritingService; + + public IrisRewritingResource(UserRepository userRepository, CourseRepository courseRepository, Optional irisRewritingService) { + this.userRepository = userRepository; + this.courseRepository = courseRepository; + this.irisRewritingService = irisRewritingService; + + } + + /** + * POST /courses/{courseId}/rewrite-text : Rewrite a given text. + * + * @param request the request containing the text to be rewritten and the corresponding variant + * @param courseId the id of the course + * @return the ResponseEntity with status 200 (OK) + */ + @EnforceAtLeastTutorInCourse + @PostMapping("courses/{courseId}/rewrite-text") + public ResponseEntity rewriteText(@RequestBody PyrisRewriteTextRequestDTO request, @PathVariable Long courseId) { + var rewritingService = irisRewritingService.orElseThrow(); + var user = userRepository.getUserWithGroupsAndAuthorities(); + var course = courseRepository.findByIdElseThrow(courseId); + rewritingService.executeRewritingPipeline(user, course, request.variant(), request.toBeRewritten()); + log.debug("REST request to rewrite text: {}", request.toBeRewritten()); + return ResponseEntity.ok().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 73e4520c32a6..3dabd0970310 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,11 +23,13 @@ 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.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.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.PyrisJob; +import de.tum.cit.aet.artemis.iris.service.pyris.job.RewritingJob; import de.tum.cit.aet.artemis.iris.service.pyris.job.TextExerciseChatJob; /** @@ -149,6 +151,31 @@ public ResponseEntity respondInTextExerciseChat(@PathVariable String runId return ResponseEntity.ok().build(); } + /** + * POST public/pyris/pipelines/rewriting/runs/:runId/status : Send the rewritten text in a status update + *

+ * Uses custom token based authentication. + * + * @param runId the ID of the job + * @param statusUpdateDTO the status update + * @param request the HTTP request + * @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 + * @return a {@link ResponseEntity} with status {@code 200 (OK)} + */ + @PostMapping("pipelines/rewriting/runs/{runId}/status") + @EnforceNothing + public ResponseEntity setRewritingJobStatus(@PathVariable String runId, @RequestBody PyrisRewritingStatusUpdateDTO statusUpdateDTO, HttpServletRequest request) { + var job = pyrisJobService.getAndAuthenticateJobFromHeaderElseThrow(request, RewritingJob.class); + if (!Objects.equals(job.jobId(), runId)) { + throw new ConflictException("Run ID in URL does not match run ID in request body", "Job", "runIdMismatch"); + } + + pyrisStatusUpdateService.handleStatusUpdate(job, statusUpdateDTO); + + return ResponseEntity.ok().build(); + } + /** * {@code POST /api/public/pyris/webhooks/ingestion/runs/{runId}/status} : Set the status of an Ingestion job. * diff --git a/src/main/webapp/app/faq/faq-update.component.html b/src/main/webapp/app/faq/faq-update.component.html index 51211a6bb77e..207f1d0c1221 100644 --- a/src/main/webapp/app/faq/faq-update.component.html +++ b/src/main/webapp/app/faq/faq-update.component.html @@ -21,7 +21,8 @@

diff --git a/src/main/webapp/app/faq/faq-update.component.ts b/src/main/webapp/app/faq/faq-update.component.ts index 086feb0e9fdc..ea0d03c191e9 100644 --- a/src/main/webapp/app/faq/faq-update.component.ts +++ b/src/main/webapp/app/faq/faq-update.component.ts @@ -1,7 +1,7 @@ -import { Component, OnInit, inject } from '@angular/core'; +import { Component, OnInit, computed, inject } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; -import { Observable } from 'rxjs'; +import { Observable, map } from 'rxjs'; import { AlertService } from 'app/core/util/alert.service'; import { onError } from 'app/shared/util/global.utils'; import { ArtemisNavigationUtilService } from 'app/utils/navigation.utils'; @@ -16,6 +16,12 @@ import { ArtemisCategorySelectorModule } from 'app/shared/category-selector/cate import { ArtemisSharedModule } from 'app/shared/shared.module'; import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; import { AccountService } from 'app/core/auth/account.service'; +import { RewriteAction } from 'app/shared/monaco-editor/model/actions/artemis-intelligence/rewrite.action'; +import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; +import { PROFILE_IRIS } from 'app/app.constants'; +import RewritingVariant from 'app/shared/monaco-editor/model/actions/artemis-intelligence/rewriting-variant'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ArtemisIntelligenceService } from 'app/shared/monaco-editor/model/actions/artemis-intelligence/artemis-intelligence.service'; @Component({ selector: 'jhi-faq-update', @@ -24,6 +30,15 @@ import { AccountService } from 'app/core/auth/account.service'; imports: [ArtemisSharedModule, ArtemisSharedComponentModule, ArtemisMarkdownEditorModule, ArtemisCategorySelectorModule], }) export class FaqUpdateComponent implements OnInit { + private alertService = inject(AlertService); + private faqService = inject(FaqService); + private activatedRoute = inject(ActivatedRoute); + private navigationUtilService = inject(ArtemisNavigationUtilService); + private router = inject(Router); + private profileService = inject(ProfileService); + private accountService = inject(AccountService); + private artemisIntelligenceService = inject(ArtemisIntelligenceService); + faq: Faq; isSaving: boolean; isAllowedToSave: boolean; @@ -33,18 +48,14 @@ export class FaqUpdateComponent implements OnInit { isAtLeastInstructor = false; domainActionsDescription = [new FormulaAction()]; + irisEnabled = toSignal(this.profileService.getProfileInfo().pipe(map((profileInfo) => profileInfo.activeProfiles.includes(PROFILE_IRIS))), { initialValue: false }); + artemisIntelligenceActions = computed(() => (this.irisEnabled() ? [new RewriteAction(this.artemisIntelligenceService, RewritingVariant.FAQ, this.courseId)] : [])); + // Icons readonly faQuestionCircle = faQuestionCircle; readonly faSave = faSave; readonly faBan = faBan; - private alertService = inject(AlertService); - private faqService = inject(FaqService); - private activatedRoute = inject(ActivatedRoute); - private navigationUtilService = inject(ArtemisNavigationUtilService); - private router = inject(Router); - private accountService = inject(AccountService); - ngOnInit() { this.isSaving = false; this.courseId = Number(this.activatedRoute.snapshot.paramMap.get('courseId')); diff --git a/src/main/webapp/app/icons/icons.ts b/src/main/webapp/app/icons/icons.ts index cabc6c1d4613..700580c34027 100644 --- a/src/main/webapp/app/icons/icons.ts +++ b/src/main/webapp/app/icons/icons.ts @@ -56,9 +56,22 @@ export const facDetails: IconDefinition = { ], } as IconDefinition; +export const facArtemisIntelligence: IconDefinition = { + prefix: 'fac' as IconPrefix, + iconName: 'artemis-intelligence' as IconName, + icon: [ + 24, // SVG view box width + 24, // SVG view box height + [], + '', + 'M6.56973 16.902H7.44723C7.74423 16.902 7.96023 16.8705 8.09523 16.8075C8.23023 16.7445 8.32923 16.6005 8.39223 16.3755L9.85023 10.7865C9.88623 10.6425 9.92223 10.4985 9.95823 10.3545C9.99423 10.2105 10.0257 10.0665 10.0527 9.9225H10.1067C10.1337 10.0665 10.1652 10.2105 10.2012 10.3545C10.2372 10.4985 10.2732 10.6425 10.3092 10.7865L11.7267 16.3755C11.7897 16.6005 11.8977 16.7445 12.0507 16.8075C12.2127 16.8705 12.4242 16.902 12.6852 16.902H13.6167C13.9227 16.902 14.1252 16.866 14.2242 16.794C14.3322 16.722 14.3862 16.623 14.3862 16.497C14.3862 16.407 14.3727 16.3125 14.3457 16.2135C14.3187 16.1055 14.2827 15.993 14.2377 15.876L11.8077 8.046C11.7177 7.767 11.5962 7.5915 11.4432 7.5195C11.2992 7.4385 11.0787 7.398 10.7817 7.398H9.40473C9.11673 7.398 8.89623 7.4385 8.74323 7.5195C8.59023 7.5915 8.46873 7.767 8.37873 8.046L5.96223 15.876C5.92623 15.993 5.89473 16.1055 5.86773 16.2135C5.83173 16.3125 5.81373 16.407 5.81373 16.497C5.81373 16.623 5.86323 16.722 5.96223 16.794C6.07023 16.866 6.27273 16.902 6.56973 16.902ZM7.77123 15.2415H12.3882V13.1895H7.77123V15.2415Z M15.9695 16.902C15.5915 16.902 15.317 16.8525 15.146 16.7535C14.975 16.6455 14.8895 16.5015 14.8895 16.3215V7.9785C14.8895 7.7985 14.975 7.659 15.146 7.56C15.317 7.452 15.5915 7.398 15.9695 7.398H16.307C16.685 7.398 16.9595 7.452 17.1305 7.56C17.3015 7.659 17.387 7.7985 17.387 7.9785V16.3215C17.387 16.5015 17.3015 16.6455 17.1305 16.7535C16.9595 16.8525 16.685 16.902 16.307 16.902H15.9695Z M7.125 0C7.74844 0 8.25 0.501562 8.25 1.125V3H10.875V1.125C10.875 0.501562 11.3766 0 12 0C12.6234 0 13.125 0.501562 13.125 1.125V3H15.75V1.125C15.75 0.501562 16.2516 0 16.875 0C17.4984 0 18 0.501562 18 1.125V3C19.6547 3 21 4.34531 21 6H22.875C23.4984 6 24 6.50156 24 7.125C24 7.74844 23.4984 8.25 22.875 8.25H21V10.875H22.875C23.4984 10.875 24 11.3766 24 12C24 12.6234 23.4984 13.125 22.875 13.125H21V15.75H22.875C23.4984 15.75 24 16.2516 24 16.875C24 17.4984 23.4984 18 22.875 18H21C21 19.6547 19.6547 21 18 21V22.875C18 23.4984 17.4984 24 16.875 24C16.2516 24 15.75 23.4984 15.75 22.875V21H13.125V22.875C13.125 23.4984 12.6234 24 12 24C11.3766 24 10.875 23.4984 10.875 22.875V21H8.25V22.875C8.25 23.4984 7.74844 24 7.125 24C6.50156 24 6 23.4984 6 22.875V21C4.34531 21 3 19.6547 3 18H1.125C0.501562 18 0 17.4984 0 16.875C0 16.2516 0.501562 15.75 1.125 15.75H3V13.125H1.125C0.501562 13.125 0 12.6234 0 12C0 11.3766 0.501562 10.875 1.125 10.875H3V8.25H1.125C0.501562 8.25 0 7.74844 0 7.125C0 6.50156 0.501562 6 1.125 6H3C3 4.34531 4.34531 3 6 3V1.125C6 0.501562 6.50156 0 7.125 0ZM6.32812 5.10938C5.65503 5.10938 5.10938 5.65503 5.10938 6.32812V17.7188C5.10938 18.3918 5.65503 18.9375 6.32812 18.9375H17.7188C18.3918 18.9375 18.9375 18.3918 18.9375 17.7188V6.32812C18.9375 5.65503 18.3918 5.10938 17.7188 5.10938H6.32812Z', + ], +} as IconDefinition; + export const artemisIconPack: IconPack = { facSidebar, facSaveSuccess, facSaveWarning, facDetails, + facArtemisIntelligence, }; diff --git a/src/main/webapp/app/shared/markdown-editor/monaco/markdown-editor-monaco.component.html b/src/main/webapp/app/shared/markdown-editor/monaco/markdown-editor-monaco.component.html index 9cfeb910578a..a7b15ad2903b 100644 --- a/src/main/webapp/app/shared/markdown-editor/monaco/markdown-editor-monaco.component.html +++ b/src/main/webapp/app/shared/markdown-editor/monaco/markdown-editor-monaco.component.html @@ -201,6 +201,30 @@ } } + + @if (displayedActions.artemisIntelligence.length > 0) { +
+ @if (artemisIntelligenceService.isLoading()) { + + } @else { + + + } +
+ } @for (action of displayedActions.meta; track action.id) { diff --git a/src/main/webapp/app/shared/markdown-editor/monaco/markdown-editor-monaco.component.ts b/src/main/webapp/app/shared/markdown-editor/monaco/markdown-editor-monaco.component.ts index bae7891424ab..31922986d8fc 100644 --- a/src/main/webapp/app/shared/markdown-editor/monaco/markdown-editor-monaco.component.ts +++ b/src/main/webapp/app/shared/markdown-editor/monaco/markdown-editor-monaco.component.ts @@ -41,7 +41,7 @@ import { AttachmentAction } from 'app/shared/monaco-editor/model/actions/attachm import { BulletedListAction } from 'app/shared/monaco-editor/model/actions/bulleted-list.action'; import { StrikethroughAction } from 'app/shared/monaco-editor/model/actions/strikethrough.action'; import { OrderedListAction } from 'app/shared/monaco-editor/model/actions/ordered-list.action'; -import { faAngleDown, faGripLines, faQuestionCircle } from '@fortawesome/free-solid-svg-icons'; +import { faAngleDown, faGripLines, faQuestionCircle, faSpinner } from '@fortawesome/free-solid-svg-icons'; import { v4 as uuid } from 'uuid'; import { FileUploadResponse, FileUploaderService } from 'app/shared/http/file-uploader.service'; import { AlertService, AlertType } from 'app/core/util/alert.service'; @@ -70,6 +70,8 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu'; import { MatButton } from '@angular/material/button'; import { ArtemisTranslatePipe } from '../../pipes/artemis-translate.pipe'; +import { ArtemisIntelligenceService } from 'app/shared/monaco-editor/model/actions/artemis-intelligence/artemis-intelligence.service'; +import { facArtemisIntelligence } from 'app/icons/icons'; export enum MarkdownEditorHeight { INLINE = 125, @@ -89,6 +91,8 @@ interface MarkdownActionsByGroup { }; // Special case due to the complex structure of lectures, attachments, and their slides lecture?: LectureAttachmentReferenceAction; + // AI assistance in the editor + artemisIntelligence: TextEditorAction[]; meta: TextEditorAction[]; } @@ -140,6 +144,7 @@ export class MarkdownEditorMonacoComponent implements AfterContentInit, AfterVie private readonly metisService = inject(MetisService, { optional: true }); private readonly fileUploaderService = inject(FileUploaderService); private readonly artemisMarkdown = inject(ArtemisMarkdownService); + protected readonly artemisIntelligenceService = inject(ArtemisIntelligenceService); @ViewChild(MonacoEditorComponent, { static: false }) monacoEditor: MonacoEditorComponent; @ViewChild('fullElement', { static: true }) fullElement: ElementRef; @@ -228,6 +233,9 @@ export class MarkdownEditorMonacoComponent implements AfterContentInit, AfterVie @Input() domainActions: TextEditorDomainAction[] = []; + @Input() + artemisIntelligenceActions: TextEditorAction[] = []; + @Input() metaActions: TextEditorAction[] = [new FullscreenAction()]; @@ -271,6 +279,7 @@ export class MarkdownEditorMonacoComponent implements AfterContentInit, AfterVie color: undefined, domain: { withoutOptions: [], withOptions: [] }, lecture: undefined, + artemisIntelligence: [], meta: [], }; @@ -295,8 +304,10 @@ export class MarkdownEditorMonacoComponent implements AfterContentInit, AfterVie readonly colorPickerHeight = 110; // Icons protected readonly faQuestionCircle = faQuestionCircle; + protected readonly faSpinner = faSpinner; protected readonly faGripLines = faGripLines; protected readonly faAngleDown = faAngleDown; + protected readonly facArtemisIntelligence = facArtemisIntelligence; // Types and values exposed to the template protected readonly LectureUnitType = LectureUnitType; protected readonly ReferenceType = ReferenceType; @@ -325,6 +336,7 @@ export class MarkdownEditorMonacoComponent implements AfterContentInit, AfterVie ) as TextEditorDomainActionWithOptions[], }, lecture: this.filterDisplayedAction(this.lectureReferenceAction), + artemisIntelligence: this.filterDisplayedActions(this.artemisIntelligenceActions ?? []), meta: this.filterDisplayedActions(this.metaActions), }; } @@ -368,6 +380,7 @@ export class MarkdownEditorMonacoComponent implements AfterContentInit, AfterVie this.domainActions, ...(this.colorAction ? [this.colorAction] : []), ...(this.lectureReferenceAction ? [this.lectureReferenceAction] : []), + ...this.artemisIntelligenceActions, this.metaActions, ] .flat() diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/adapter/monaco-text-editor.adapter.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/adapter/monaco-text-editor.adapter.ts index 0f1456bec524..ea28aa20229e 100644 --- a/src/main/webapp/app/shared/monaco-editor/model/actions/adapter/monaco-text-editor.adapter.ts +++ b/src/main/webapp/app/shared/monaco-editor/model/actions/adapter/monaco-text-editor.adapter.ts @@ -232,4 +232,11 @@ export class MonacoTextEditorAdapter implements TextEditor { const modifier = MonacoTextEditorAdapter.MODIFIER_MAP.get(keybinding.getModifier()) ?? 0; return keyCode | modifier; } + + getFullText(): string { + const firstPosition = new TextEditorPosition(1, 1); + const lastPosition = this.getEndPosition(); + const range = new TextEditorRange(firstPosition, lastPosition); + return this.getTextAtRange(range); + } } diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/adapter/text-editor.interface.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/adapter/text-editor.interface.ts index ff6c204b4414..c06d9a77aabe 100644 --- a/src/main/webapp/app/shared/monaco-editor/model/actions/adapter/text-editor.interface.ts +++ b/src/main/webapp/app/shared/monaco-editor/model/actions/adapter/text-editor.interface.ts @@ -53,6 +53,12 @@ export interface TextEditor { */ getTextAtRange(range: TextEditorRange): string; + /** + * Retrieves the full text of the editor. + * Line endings are normalized to '\n'. + */ + getFullText(): string; + /** * Retrieves the text of the line at the given line number in the editor. * @param lineNumber The line number to get the text from. Line numbers start at 1. diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/artemis-intelligence/artemis-intelligence.service.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/artemis-intelligence/artemis-intelligence.service.ts new file mode 100644 index 000000000000..3aca4d2edbca --- /dev/null +++ b/src/main/webapp/app/shared/monaco-editor/model/actions/artemis-intelligence/artemis-intelligence.service.ts @@ -0,0 +1,66 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, computed, inject, signal } from '@angular/core'; +import { Observable } from 'rxjs'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; +import RewritingVariant from 'app/shared/monaco-editor/model/actions/artemis-intelligence/rewriting-variant'; +import { AlertService } from 'app/core/util/alert.service'; + +/** + * Service providing shared functionality for Artemis Intelligence of the markdown editor. + * This service is intended to be used by the AI actions of the Monaco editors. + */ +@Injectable({ providedIn: 'root' }) +export class ArtemisIntelligenceService { + public resourceUrl = 'api/courses'; + + private http = inject(HttpClient); + private jhiWebsocketService = inject(WebsocketService); + private alertService = inject(AlertService); + + private isLoadingRewrite = signal(false); + isLoading = computed(() => this.isLoadingRewrite()); + + /** + * Triggers the rewriting pipeline via HTTP and subscribes to its WebSocket updates. + * @param toBeRewritten The text to be rewritten. + * @param rewritingVariant The variant for rewriting. + * @param courseId The ID of the course to which the rewritten text belongs. + * @return Observable that emits the rewritten text when available. + */ + rewrite(toBeRewritten: string, rewritingVariant: RewritingVariant, courseId: number): Observable { + this.isLoadingRewrite.set(true); + return new Observable((observer) => { + this.http + .post(`${this.resourceUrl}/${courseId}/rewrite-text`, { + toBeRewritten: toBeRewritten, + variant: rewritingVariant, + }) + .subscribe({ + next: () => { + const websocketTopic = `/user/topic/iris/rewriting/${courseId}`; + this.jhiWebsocketService.subscribe(websocketTopic); + this.jhiWebsocketService.receive(websocketTopic).subscribe({ + next: (update: any) => { + if (update.result) { + observer.next(update.result); + observer.complete(); + this.isLoadingRewrite.set(false); + this.jhiWebsocketService.unsubscribe(websocketTopic); + this.alertService.success('artemisApp.markdownEditor.artemisIntelligence.alerts.rewrite.success'); + } + }, + error: (error) => { + observer.error(error); + this.isLoadingRewrite.set(false); + this.jhiWebsocketService.unsubscribe(websocketTopic); + }, + }); + }, + error: (error) => { + this.isLoadingRewrite.set(false); + observer.error(error); + }, + }); + }); + } +} diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/artemis-intelligence/rewrite.action.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/artemis-intelligence/rewrite.action.ts new file mode 100644 index 000000000000..73ac1525d675 --- /dev/null +++ b/src/main/webapp/app/shared/monaco-editor/model/actions/artemis-intelligence/rewrite.action.ts @@ -0,0 +1,29 @@ +import { TextEditorAction } from 'app/shared/monaco-editor/model/actions/text-editor-action.model'; +import { TextEditor } from 'app/shared/monaco-editor/model/actions/adapter/text-editor.interface'; +import RewritingVariant from 'app/shared/monaco-editor/model/actions/artemis-intelligence/rewriting-variant'; +import { ArtemisIntelligenceService } from 'app/shared/monaco-editor/model/actions/artemis-intelligence/artemis-intelligence.service'; + +/** + * Artemis Intelligence action for rewriting in the editor. + */ +export class RewriteAction extends TextEditorAction { + static readonly ID = 'artemisIntelligence.rewrite.action'; + + element?: HTMLElement; + + constructor( + private readonly artemisIntelligenceService: ArtemisIntelligenceService, + private readonly rewritingVariant: RewritingVariant, + private readonly courseId: number, + ) { + super(RewriteAction.ID, 'artemisApp.markdownEditor.artemisIntelligence.commands.rewrite'); + } + + /** + * Runs the rewriting of the markdown content of the editor. + * @param editor The editor in which to rewrite the markdown. + */ + run(editor: TextEditor): void { + this.rewriteMarkdown(editor, this.artemisIntelligenceService, this.rewritingVariant, this.courseId); + } +} diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/artemis-intelligence/rewriting-variant.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/artemis-intelligence/rewriting-variant.ts new file mode 100644 index 000000000000..80ab65fddeed --- /dev/null +++ b/src/main/webapp/app/shared/monaco-editor/model/actions/artemis-intelligence/rewriting-variant.ts @@ -0,0 +1,6 @@ +enum RewritingVariant { + FAQ = 'FAQ', + PROBLEM_STATEMENT = 'PROBLEM_STATEMENT', +} + +export default RewritingVariant; diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/text-editor-action.model.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/text-editor-action.model.ts index 62e037868ce0..fe5da5e71942 100644 --- a/src/main/webapp/app/shared/monaco-editor/model/actions/text-editor-action.model.ts +++ b/src/main/webapp/app/shared/monaco-editor/model/actions/text-editor-action.model.ts @@ -7,6 +7,8 @@ import { TextEditorRange } from 'app/shared/monaco-editor/model/actions/adapter/ import { TextEditorPosition } from 'app/shared/monaco-editor/model/actions/adapter/text-editor-position.model'; import { TextEditorCompletionItem } from 'app/shared/monaco-editor/model/actions/adapter/text-editor-completion-item.model'; import { TextEditorKeybinding } from 'app/shared/monaco-editor/model/actions/adapter/text-editor-keybinding.model'; +import RewritingVariant from 'app/shared/monaco-editor/model/actions/artemis-intelligence/rewriting-variant'; +import { ArtemisIntelligenceService } from 'app/shared/monaco-editor/model/actions/artemis-intelligence/artemis-intelligence.service'; export abstract class TextEditorAction implements Disposable { id: string; @@ -293,4 +295,22 @@ export abstract class TextEditorAction implements Disposable { } editor.layout(); } + + /** + * Rewrites the given text using Artemis Intelligence. If no text is provided, the editor's will return undefined. + * @param editor The editor to toggle the text rewriting for. + * @param artemisIntelligence The Artemis Intelligence service to use for rewriting the text. + * @param variant The variant to use for rewriting the text. + * @param courseId The ID of the course to use for rewriting the text (for tracking purposes). + */ + rewriteMarkdown(editor: TextEditor, artemisIntelligence: ArtemisIntelligenceService, variant: RewritingVariant, courseId: number): void { + const text = editor.getFullText(); + if (text) { + artemisIntelligence.rewrite(text, variant, courseId).subscribe({ + next: (message) => { + this.replaceTextAtRange(editor, new TextEditorRange(new TextEditorPosition(1, 1), this.getEndPosition(editor)), message); + }, + }); + } + } } diff --git a/src/main/webapp/i18n/de/markdownEditor.json b/src/main/webapp/i18n/de/markdownEditor.json index d2d331be87b7..aa77392955f3 100644 --- a/src/main/webapp/i18n/de/markdownEditor.json +++ b/src/main/webapp/i18n/de/markdownEditor.json @@ -12,7 +12,18 @@ "slideNotFound": "Referenzierte Folie wurde nicht gefunden", "imageNotFound": "Referenziertes Bild wurde nicht gefunden" }, - "slideWithNumber": "Folie {{ number }}" + "slideWithNumber": "Folie {{ number }}", + "artemisIntelligence": { + "tooltip": "Artemis Intelligence", + "commands": { + "rewrite": "Umformulieren" + }, + "alerts": { + "rewrite": { + "success": "Der Text wurde umgeschrieben. Drücke Strg+Z / Cmd+Z, um den Originaltext wiederherzustellen." + } + } + } } } } diff --git a/src/main/webapp/i18n/en/markdownEditor.json b/src/main/webapp/i18n/en/markdownEditor.json index 26c9c39e7fc3..44666915a0d7 100644 --- a/src/main/webapp/i18n/en/markdownEditor.json +++ b/src/main/webapp/i18n/en/markdownEditor.json @@ -12,7 +12,18 @@ "slideNotFound": "Referenced slide was not found", "imageNotFound": "Referenced image was not found" }, - "slideWithNumber": "Slide {{ number }}" + "slideWithNumber": "Slide {{ number }}", + "artemisIntelligence": { + "tooltip": "Artemis Intelligence", + "commands": { + "rewrite": "Rewrite" + }, + "alerts": { + "rewrite": { + "success": "The text was rewritten. Press Ctrl+Z / Cmd+Z to restore the original text." + } + } + } } } } 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 db8e032aca21..a71d07785f7b 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 @@ -37,6 +37,7 @@ 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.lectureingestionwebhook.PyrisWebhookLectureIngestionExecutionDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.rewriting.PyrisRewritingPipelineExecutionDTO; @Component @Profile(PROFILE_IRIS) @@ -162,6 +163,20 @@ public void mockRunCompetencyExtractionResponseAnd(Consumer executionDTOConsumer) { + // @formatter:off + mockServer + .expect(ExpectedCount.once(), requestTo(pipelinesApiURL + "/rewriting/faq/run")) + .andExpect(method(HttpMethod.POST)) + .andRespond(request -> { + var mockRequest = (MockClientHttpRequest) request; + var dto = mapper.readValue(mockRequest.getBodyAsString(), PyrisRewritingPipelineExecutionDTO.class); + executionDTOConsumer.accept(dto); + return MockRestResponseCreators.withRawStatus(HttpStatus.ACCEPTED.value()).createResponse(request); + }); + // @formatter:on + } + public void mockIngestionWebhookRunResponse(Consumer responseConsumer) { mockServer.expect(ExpectedCount.once(), requestTo(webhooksApiURL + "/lectures/fullIngestion")).andExpect(method(HttpMethod.POST)).andRespond(request -> { var mockRequest = (MockClientHttpRequest) request; diff --git a/src/test/java/de/tum/cit/aet/artemis/iris/architecture/IrisCodeStyleArchitectureTest.java b/src/test/java/de/tum/cit/aet/artemis/iris/architecture/IrisCodeStyleArchitectureTest.java index 28c42c67d205..9ed370ccf0c3 100644 --- a/src/test/java/de/tum/cit/aet/artemis/iris/architecture/IrisCodeStyleArchitectureTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/iris/architecture/IrisCodeStyleArchitectureTest.java @@ -16,6 +16,6 @@ protected int dtoAsAnnotatedRecordThreshold() { @Override protected int dtoNameEndingThreshold() { - return 4; + return 5; } } diff --git a/src/test/javascript/spec/component/faq/faq-update.component.spec.ts b/src/test/javascript/spec/component/faq/faq-update.component.spec.ts index 275bd4cb4f48..df52bcd9683d 100644 --- a/src/test/javascript/spec/component/faq/faq-update.component.spec.ts +++ b/src/test/javascript/spec/component/faq/faq-update.component.spec.ts @@ -16,6 +16,8 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { AlertService } from 'app/core/util/alert.service'; import { FaqCategory } from 'app/entities/faq-category.model'; import { ArtemisMarkdownEditorModule } from 'app/shared/markdown-editor/markdown-editor.module'; +import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; +import { ProfileInfo } from 'app/shared/layouts/profiles/profile-info.model'; describe('FaqUpdateComponent', () => { let faqUpdateComponentFixture: ComponentFixture; @@ -36,6 +38,7 @@ describe('FaqUpdateComponent', () => { faq1.questionAnswer = 'questionAnswer'; faq1.categories = [new FaqCategory('category1', '#94a11c')]; courseId = 1; + const mockProfileInfo = { activeProfiles: ['iris'] } as ProfileInfo; TestBed.configureTestingModule({ imports: [ArtemisTestModule, MockModule(ArtemisMarkdownEditorModule), MockModule(BrowserAnimationsModule)], declarations: [FaqUpdateComponent, MockPipe(HtmlForMarkdownPipe), MockRouterLinkDirective], @@ -73,6 +76,10 @@ describe('FaqUpdateComponent', () => { ); }, }), + + MockProvider(ProfileService, { + getProfileInfo: () => of(mockProfileInfo), + }), ], }).compileComponents(); @@ -242,4 +249,10 @@ describe('FaqUpdateComponent', () => { expect(alertServiceStub).toHaveBeenCalledOnce(); flush(); })); + + it('should handleMarkdownChange properly ', () => { + faqUpdateComponent.faq = { questionTitle: 'test1', questionAnswer: 'answer' } as Faq; + faqUpdateComponent.handleMarkdownChange('test'); + expect(faqUpdateComponent.faq.questionAnswer).toEqual('test'); + }); }); diff --git a/src/test/javascript/spec/component/shared/metis/postings-markdown-editor/postings-markdown-editor.component.spec.ts b/src/test/javascript/spec/component/shared/metis/postings-markdown-editor/postings-markdown-editor.component.spec.ts index 276d3e82fd7d..216be110cf91 100644 --- a/src/test/javascript/spec/component/shared/metis/postings-markdown-editor/postings-markdown-editor.component.spec.ts +++ b/src/test/javascript/spec/component/shared/metis/postings-markdown-editor/postings-markdown-editor.component.spec.ts @@ -84,6 +84,7 @@ describe('PostingsMarkdownEditor', () => { revealRange: jest.fn(), addCompleter: jest.fn(), addPasteListener: jest.fn(), + getFullText: jest.fn(), }; const mockPositionStrategy = { diff --git a/src/test/javascript/spec/service/artemis-intelligence.service.spec.ts b/src/test/javascript/spec/service/artemis-intelligence.service.spec.ts new file mode 100644 index 000000000000..8c3635ca6e3f --- /dev/null +++ b/src/test/javascript/spec/service/artemis-intelligence.service.spec.ts @@ -0,0 +1,75 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { ArtemisTestModule } from '../test.module'; + +import { ArtemisIntelligenceService } from 'app/shared/monaco-editor/model/actions/artemis-intelligence/artemis-intelligence.service'; +import { BehaviorSubject } from 'rxjs'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; +import RewritingVariant from 'app/shared/monaco-editor/model/actions/artemis-intelligence/rewriting-variant'; + +describe('ArtemisIntelligenceService', () => { + let httpMock: HttpTestingController; + let service: ArtemisIntelligenceService; + + beforeEach(() => { + const mockWebsocketService = { + subscribe: jest.fn(), + unsubscribe: jest.fn(), + receive: jest.fn().mockReturnValue(new BehaviorSubject({ result: 'Rewritten Text' })), + }; + + TestBed.configureTestingModule({ + imports: [ArtemisTestModule], + providers: [provideHttpClient(), provideHttpClientTesting(), { provide: WebsocketService, useValue: mockWebsocketService }], + }); + + httpMock = TestBed.inject(HttpTestingController); + service = TestBed.inject(ArtemisIntelligenceService); + }); + + afterEach(() => { + httpMock.verify(); + }); + + describe('Service methods', () => { + it('should trigger rewriting pipeline and return rewritten text', () => { + const toBeRewritten = 'OriginalText'; + const rewritingVariant = RewritingVariant.FAQ; + const courseId = 1; + + service.rewrite(toBeRewritten, rewritingVariant, courseId).subscribe((rewrittenText) => { + expect(rewrittenText).toBe('Rewritten Text'); + }); + + const req = httpMock.expectOne({ + url: `api/courses/${courseId}/rewrite-text`, + method: 'POST', + }); + + req.flush(null); + }); + + it('should handle HTTP error correctly', () => { + const toBeRewritten = 'OriginalText'; + const rewritingVariant = RewritingVariant.FAQ; + const courseId = 1; + + service.rewrite(toBeRewritten, rewritingVariant, courseId).subscribe({ + next: () => { + throw new Error('Expected error, but got success response'); + }, + error: (err) => { + expect(err).toBe('HTTP Request Error:'); + }, + }); + + const req = httpMock.expectOne({ + url: `api/courses/${courseId}/rewrite-text`, + method: 'POST', + }); + + req.flush(null, { status: 400, statusText: 'Bad Request' }); + }); + }); +});