Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
import de.tum.cit.aet.artemis.course.domain.Course;
import de.tum.cit.aet.artemis.exercise.domain.Exercise;
import de.tum.cit.aet.artemis.exercise.domain.event.ExerciseVersionCreatedEvent;
import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise;

/**
* Feeds the automatic competency pipeline whenever an exercise version is created. Hooks into the
Expand Down Expand Up @@ -63,11 +62,6 @@ public void onExerciseVersionCreated(ExerciseVersionCreatedEvent event) {
if (exercise == null || exercise.getId() == null || exercise.isExamExercise()) {
return;
}
// Orchestrator currently only supports programming exercises — recording other types would
// burn the per-course daily cap on guaranteed-failure runs.
if (!(exercise instanceof ProgrammingExercise)) {
return;
}
Course course = exercise.getCourseViaExerciseGroupOrCourseMember();
if (course == null || course.getId() == null) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,10 @@
import de.tum.cit.aet.artemis.atlas.dto.ExtractedContentDTO;
import de.tum.cit.aet.artemis.atlas.service.ContentChangeAccumulatorService.BatchClaim;
import de.tum.cit.aet.artemis.core.security.SecurityUtils;
import de.tum.cit.aet.artemis.exercise.domain.Exercise;
import de.tum.cit.aet.artemis.exercise.repository.ExerciseRepository;
import de.tum.cit.aet.artemis.localci.service.distributed.api.DistributedDataProvider;
import de.tum.cit.aet.artemis.localci.service.distributed.api.map.DistributedMap;
import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise;
import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseRepository;

/**
* Entry point for autonomous competency management runs.
Expand Down Expand Up @@ -93,7 +93,7 @@ public class CompetencyOrchestrationService {

private static final String USER_DATA_END = "<<<END_USER_DATA>>>";

private final ProgrammingExerciseRepository programmingExerciseRepository;
private final ExerciseRepository exerciseRepository;

private final ContentExtractionService contentExtractionService;

Expand Down Expand Up @@ -123,11 +123,11 @@ public class CompetencyOrchestrationService {

private volatile DistributedMap<Long, RunInfo> runMap;

public CompetencyOrchestrationService(ProgrammingExerciseRepository programmingExerciseRepository, ContentExtractionService contentExtractionService,
public CompetencyOrchestrationService(ExerciseRepository exerciseRepository, ContentExtractionService contentExtractionService,
OrchestratorToolsService orchestratorToolsService, AtlasPromptTemplateService templateService, @Nullable ChatClient chatClient,
AtlasAgentToolCallbackService toolCallbackFactory, Optional<DistributedDataProvider> distributedDataProvider, AtlasOrchestratorProperties properties,
ContentChangeAccumulatorService contentChangeAccumulatorService, LLMTokenUsageService llmTokenUsageService, UserRepository userRepository) {
this.programmingExerciseRepository = programmingExerciseRepository;
this.exerciseRepository = exerciseRepository;
this.contentExtractionService = contentExtractionService;
this.orchestratorToolsService = orchestratorToolsService;
this.templateService = templateService;
Expand Down Expand Up @@ -219,11 +219,11 @@ private void releaseRun(long courseId, RunInfo claim) {
}

/**
* Run one orchestration pass for the given programming exercise. The orchestrator plans
* Run one orchestration pass for the given exercise (any type). The orchestrator plans
* internally and executes its plan by calling write tools — each tool call mutates state
* immediately and appends to the applied-actions list returned in the result.
*
* @param exerciseId the programming exercise to orchestrate competencies for
* @param exerciseId the exercise to orchestrate competencies for
* @return one of:
* <ul>
* <li>{@link CompetencyOrchestrationResultDTO.Status#SUCCESS} with the LLM's summary message and the applied actions;</li>
Expand All @@ -235,7 +235,7 @@ private void releaseRun(long courseId, RunInfo claim) {
* </ul>
*/
public CompetencyOrchestrationResultDTO run(long exerciseId) {
ProgrammingExercise exercise = programmingExerciseRepository.findByIdElseThrow(exerciseId);
Exercise exercise = exerciseRepository.findByIdElseThrow(exerciseId);
CompetencyOrchestrationResultDTO precheck = precheckExercise(exercise);
if (precheck != null) {
return precheck;
Expand Down Expand Up @@ -266,7 +266,7 @@ public CompetencyOrchestrationResultDTO run(long exerciseId) {
* observes {@link CompetencyOrchestrationResultDTO.Status#IN_PROGRESS}.
*
* @param courseId the course whose buffered batch is being drained
* @param exerciseIds programming-exercise ids in the batch
* @param exerciseIds exercise ids in the batch (any type)
* @return the single batch result; {@code SUCCESS} when the run completed, {@code NO_OP} when no
* claimed exercise was applicable (so nothing was processed), {@code IN_PROGRESS} when
* another run holds the course lock
Expand All @@ -275,7 +275,7 @@ public CompetencyOrchestrationResultDTO runBatch(long courseId, Set<Long> exerci
if (chatClient == null) {
return CompetencyOrchestrationResultDTO.failed("Atlas chat model is not configured.", CompetencyOrchestrationResultDTO.FailureReason.NO_CHAT_CLIENT);
}
List<ProgrammingExercise> exercises = resolveBatchExercises(courseId, exerciseIds);
List<Exercise> exercises = resolveBatchExercises(courseId, exerciseIds);
if (exercises.isEmpty()) {
return CompetencyOrchestrationResultDTO.noOp("No applicable exercises in batch.");
}
Expand Down Expand Up @@ -306,7 +306,7 @@ public CompetencyOrchestrationResultDTO runBatch(long courseId, Set<Long> exerci
* @return the single batch result covering the clicked exercise and any queued changes
*/
public CompetencyOrchestrationResultDTO runWithQueuedFlush(long exerciseId) {
ProgrammingExercise clicked = programmingExerciseRepository.findByIdElseThrow(exerciseId);
Exercise clicked = exerciseRepository.findByIdElseThrow(exerciseId);
CompetencyOrchestrationResultDTO precheck = precheckExercise(clicked);
if (precheck != null) {
return precheck;
Expand All @@ -328,7 +328,7 @@ public CompetencyOrchestrationResultDTO runWithQueuedFlush(long exerciseId) {
Set<Long> mergedExerciseIds = new LinkedHashSet<>(queuedExerciseIds);
mergedExerciseIds.add(exerciseId);
log.info("Atlas orchestrator (manual flush) course {} running batch of {} exercise(s) (including clicked exercise {})", courseId, mergedExerciseIds.size(), exerciseId);
List<ProgrammingExercise> exercises = resolveBatchExercises(courseId, mergedExerciseIds);
List<Exercise> exercises = resolveBatchExercises(courseId, mergedExerciseIds);
if (exercises.isEmpty()) {
return CompetencyOrchestrationResultDTO.noOp("No applicable exercises in batch.");
}
Expand All @@ -345,19 +345,19 @@ public CompetencyOrchestrationResultDTO runWithQueuedFlush(long exerciseId) {
}

/**
* Resolves a set of exercise ids into the programming exercises eligible for orchestration,
* Resolves a set of exercise ids into the exercises eligible for orchestration,
* dropping unknown and exam exercises and — as a defence against a stale/corrupt accumulator
* entry — any whose owning course does not match {@code courseId} (mixing course content is
* never correct). Order of {@code exerciseIds} is preserved.
*/
private List<ProgrammingExercise> resolveBatchExercises(long courseId, Collection<Long> exerciseIds) {
Map<Long, ProgrammingExercise> byId = new HashMap<>();
for (ProgrammingExercise exercise : programmingExerciseRepository.findAllById(exerciseIds)) {
private List<Exercise> resolveBatchExercises(long courseId, Collection<Long> exerciseIds) {
Map<Long, Exercise> byId = new HashMap<>();
for (Exercise exercise : exerciseRepository.findAllById(exerciseIds)) {
byId.put(exercise.getId(), exercise);
}
List<ProgrammingExercise> exercises = new ArrayList<>();
List<Exercise> exercises = new ArrayList<>();
for (Long id : exerciseIds) {
ProgrammingExercise exercise = byId.get(id);
Exercise exercise = byId.get(id);
if (exercise == null) {
log.info("Atlas orchestrator (batch) skipping exercise {}: not found", id);
continue;
Expand All @@ -382,7 +382,7 @@ private List<ProgrammingExercise> resolveBatchExercises(long courseId, Collectio
* caller may proceed.
*/
@Nullable
private CompetencyOrchestrationResultDTO precheckExercise(ProgrammingExercise exercise) {
private CompetencyOrchestrationResultDTO precheckExercise(Exercise exercise) {
if (exercise.isExamExercise()) {
log.info("Atlas orchestrator rejected for exam exercise {}", exercise.getId());
return CompetencyOrchestrationResultDTO.failed("Atlas orchestrator only operates on course exercises.",
Expand All @@ -399,7 +399,7 @@ private CompetencyOrchestrationResultDTO precheckExercise(ProgrammingExercise ex
* Orchestrates a single exercise. Caller is responsible for holding the per-course
* {@link #runMap} claim around all invocations within a logical run.
*/
private CompetencyOrchestrationResultDTO orchestrateExercise(ProgrammingExercise exercise, long courseId) {
private CompetencyOrchestrationResultDTO orchestrateExercise(Exercise exercise, long courseId) {
long exerciseId = exercise.getId();
String systemPrompt;
try {
Expand Down Expand Up @@ -441,13 +441,29 @@ private CompetencyOrchestrationResultDTO orchestrateExercise(ProgrammingExercise
* into a single numbered EXERCISE CHANGE BATCH; the prompt already reasons across multiple
* entries. Caller is responsible for holding the per-course {@link #runMap} claim.
*/
private CompetencyOrchestrationResultDTO orchestrateBatch(List<ProgrammingExercise> exercises, long courseId) {
private CompetencyOrchestrationResultDTO orchestrateBatch(List<Exercise> exercises, long courseId) {
String systemPrompt;
// Ids dropped mid-run because their extraction threw. On FAILED the caller requeues the whole
// batch (skipped ids included); on SUCCESS/PARTIAL the caller keeps the drained bucket, so we
// must requeue exactly these here or their orchestration is lost until a fresh version event.
Set<Long> skipped = new LinkedHashSet<>();
try {
List<ExerciseChange> changes = new ArrayList<>();
for (ProgrammingExercise exercise : exercises) {
ExtractedContentDTO extracted = contentExtractionService.extractContent(exercise);
changes.add(new ExerciseChange(exercise.getId(), extracted.title(), extracted.extractedLearningText()));
for (Exercise exercise : exercises) {
try {
ExtractedContentDTO extracted = contentExtractionService.extractContent(exercise);
changes.add(new ExerciseChange(exercise.getId(), extracted.title(), extracted.extractedLearningText()));
}
catch (Exception ex) {
// Isolate per-exercise failures (e.g. a quiz deleted mid-run whose refetch throws) so one bad entry
// does not drop the whole batch and burn the course's daily-run slot. The exercise is skipped this run.
skipped.add(exercise.getId());
log.warn("Atlas orchestrator (batch) skipping exercise {} for course {}: {}", exercise.getId(), courseId, ex.getMessage(), ex);
}
}
if (changes.isEmpty()) {
log.warn("Atlas orchestrator (batch) has no extractable exercises for course {}", courseId);
return CompetencyOrchestrationResultDTO.failed("Atlas orchestrator run failed.", CompetencyOrchestrationResultDTO.FailureReason.INTERNAL_ERROR);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
CompetencyIndexResponseDTO competencyIndex = orchestratorToolsService.listCompetencyIndex(courseId);
String renderedIndex = renderCompetencyIndex(competencyIndex);
Expand All @@ -467,16 +483,37 @@ private CompetencyOrchestrationResultDTO orchestrateBatch(List<ProgrammingExerci
catch (Exception ex) {
log.warn("Atlas orchestrator (batch) LLM call failed for course {} after applying {} action(s): {}", courseId, appliedActions.size(), ex.getMessage(), ex);
if (appliedActions.isEmpty()) {
// FAILED: nothing committed — the caller requeues the whole batch, which already covers the skipped ids.
return CompetencyOrchestrationResultDTO.failed("Atlas orchestrator run failed.", CompetencyOrchestrationResultDTO.FailureReason.LLM_ERROR);
}
// PARTIAL: the caller must not requeue the batch (would re-apply committed mutations), so the skipped ids
// would otherwise be lost — requeue exactly them here (they had no mutation, so this is safe).
requeueSkippedExercises(courseId, skipped);
return CompetencyOrchestrationResultDTO.partial("Atlas orchestrator run failed after applying " + appliedActions.size() + " action(s).", List.copyOf(appliedActions),
CompetencyOrchestrationResultDTO.FailureReason.LLM_ERROR);
}
log.info("Atlas orchestrator (batch) completed for course {} over {} exercise(s) with {} applied action(s)", courseId, exercises.size(), appliedActions.size());
// SUCCESS: the caller keeps the drained bucket, so requeue the ids skipped mid-run or their orchestration is lost.
requeueSkippedExercises(courseId, skipped);
String summary = content.isBlank() ? "Atlas orchestrator run completed." : content;
return CompetencyOrchestrationResultDTO.success(summary, List.copyOf(appliedActions));
}

/**
* Requeue exercise ids that were dropped mid-batch because their content extraction threw. Only
* called on the SUCCESS / PARTIAL paths, where the caller keeps the drained accumulator bucket;
* on FAILED the caller requeues the whole batch instead. The reservation is kept (a run did
* happen), so the per-course daily cap still bounds retries. Safe on PARTIAL: skipped ids never
* reached the prompt, so no mutation was committed for them.
*/
private void requeueSkippedExercises(long courseId, Set<Long> skipped) {
if (skipped.isEmpty()) {
return;
}
log.info("Atlas orchestrator (batch) requeueing {} exercise(s) skipped mid-run for course {}: {}", skipped.size(), courseId, skipped);
contentChangeAccumulatorService.requeueAfterFailedRun(courseId, skipped);
}

/**
* Drive the Spring AI tool-calling loop. {@link #run(long)} guarantees {@link #chatClient} is
* non-null before we get here, so no null check is needed and no null is returned. Returns the
Expand Down
Loading
Loading