Skip to content

Commit 9ef5bb5

Browse files
Development: Add per-course auto-orchestration configuration (#13032)
Co-authored-by: Maximilian Anzinger <44003963+MaximilianAnzinger@users.noreply.github.com>
1 parent 988688a commit 9ef5bb5

34 files changed

Lines changed: 1600 additions & 69 deletions
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package de.tum.cit.aet.artemis.atlas.api;
2+
3+
import org.springframework.context.annotation.Conditional;
4+
import org.springframework.context.annotation.Lazy;
5+
import org.springframework.stereotype.Controller;
6+
7+
import de.tum.cit.aet.artemis.atlas.config.AtlasEnabled;
8+
import de.tum.cit.aet.artemis.atlas.service.ContentChangeAccumulatorService;
9+
10+
/**
11+
* API facade for a course's auto-orchestration configuration. Keeps the {@code course} module's only
12+
* {@code atlas} touchpoint behind {@code *.api}: the course update flow flushes buffered content changes
13+
* when auto-orchestration is disabled.
14+
* <p>
15+
* Reading and writing the configuration itself deliberately does <em>not</em> go through this facade. This
16+
* bean is conditional on the Atlas module, whereas the settings must be preserved across course updates
17+
* regardless of whether Atlas is active. They therefore live on the (unconditional) {@code CourseConfiguration}
18+
* and are read and written by the course update flow directly.
19+
*/
20+
@Controller
21+
@Conditional(AtlasEnabled.class)
22+
@Lazy
23+
public class CourseAutoOrchestrationApi extends AbstractAtlasApi {
24+
25+
private final ContentChangeAccumulatorService contentChangeAccumulatorService;
26+
27+
public CourseAutoOrchestrationApi(ContentChangeAccumulatorService contentChangeAccumulatorService) {
28+
this.contentChangeAccumulatorService = contentChangeAccumulatorService;
29+
}
30+
31+
/**
32+
* Drops a course's buffered content changes. Called when a course disables auto-orchestration so a
33+
* stale batch buffered while it was enabled cannot fire (e.g. on re-enable within the debounce
34+
* window or a scheduler tick before the change propagates).
35+
*
36+
* @param courseId the course whose buffered content changes should be dropped
37+
*/
38+
public void flushBufferedContentChanges(long courseId) {
39+
contentChangeAccumulatorService.flush(courseId);
40+
}
41+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package de.tum.cit.aet.artemis.atlas.dto;
2+
3+
import org.jspecify.annotations.Nullable;
4+
5+
import com.fasterxml.jackson.annotation.JsonInclude;
6+
7+
/**
8+
* Lightweight projection of a course's Atlas auto-orchestration configuration. Read on the
9+
* accumulator hot path ({@code record} / {@code claimDueBatch} / {@code listDueCourseIds}) so the
10+
* pipeline can resolve the per-course kill switch and the debounce / daily-cap overrides without
11+
* loading the full configuration entity. The config stays authoritative in the database and is
12+
* never duplicated into the distributed accumulator state.
13+
*
14+
* @param autoOrchestratorEnabled hard per-course kill switch for the auto-orchestration pipeline
15+
* @param debounceWindowSecondsOverride per-course debounce window override in seconds, or {@code null} to use the global default
16+
* @param maxDailyOrchestrationOverride per-course daily run cap override, or {@code null} to use the global default
17+
*/
18+
@JsonInclude(JsonInclude.Include.NON_EMPTY)
19+
public record CourseAutoOrchestrationConfigDTO(boolean autoOrchestratorEnabled, @Nullable Integer debounceWindowSecondsOverride, @Nullable Integer maxDailyOrchestrationOverride) {
20+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package de.tum.cit.aet.artemis.atlas.dto;
2+
3+
import com.fasterxml.jackson.annotation.JsonInclude;
4+
5+
/**
6+
* Global default values for the per-course Atlas auto-orchestration overrides, surfaced to the
7+
* course-settings form so it can show the instructor what an empty (unset) override resolves to.
8+
* Sourced from {@code AtlasOrchestratorProperties} (server-side YAML); the per-course overrides on
9+
* {@link de.tum.cit.aet.artemis.course.domain.CourseConfiguration} fall back to these defaults when
10+
* not set.
11+
*
12+
* @param debounceWindowSeconds global default debounce window in seconds
13+
* @param maxDailyOrchestrations global default daily run cap
14+
*/
15+
@JsonInclude(JsonInclude.Include.NON_EMPTY)
16+
public record OrchestratorDefaultsDTO(int debounceWindowSeconds, int maxDailyOrchestrations) {
17+
}

src/main/java/de/tum/cit/aet/artemis/atlas/service/AutonomousCompetencyExerciseEventListener.java

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
package de.tum.cit.aet.artemis.atlas.service;
22

3+
import java.util.Collections;
4+
import java.util.Set;
5+
36
import org.slf4j.Logger;
47
import org.slf4j.LoggerFactory;
58
import org.springframework.context.annotation.Conditional;
@@ -9,12 +12,15 @@
912
import org.springframework.stereotype.Component;
1013

1114
import de.tum.cit.aet.artemis.atlas.config.AtlasEnabled;
15+
import de.tum.cit.aet.artemis.atlas.dto.CourseAutoOrchestrationConfigDTO;
1216
import de.tum.cit.aet.artemis.core.security.SecurityUtils;
1317
import de.tum.cit.aet.artemis.core.service.feature.Feature;
1418
import de.tum.cit.aet.artemis.core.service.feature.FeatureToggleService;
1519
import de.tum.cit.aet.artemis.course.domain.Course;
20+
import de.tum.cit.aet.artemis.course.repository.CourseConfigurationRepository;
1621
import de.tum.cit.aet.artemis.exercise.domain.Exercise;
1722
import de.tum.cit.aet.artemis.exercise.domain.event.ExerciseVersionCreatedEvent;
23+
import de.tum.cit.aet.artemis.exercise.service.ExerciseVersionService;
1824

1925
/**
2026
* Feeds the automatic competency pipeline whenever an exercise version is created. Hooks into the
@@ -23,8 +29,16 @@
2329
* resource.
2430
* <p>
2531
* Everything is gated behind the {@link Feature#AtlasAgent} toggle, which is
26-
* disabled by default — instructors can opt in per-instance via the feature-toggle admin UI. Exam
27-
* exercises are skipped because competency management is scoped to course content only.
32+
* disabled by default — instructors can opt in per-instance via the feature-toggle admin UI — and,
33+
* additionally, behind the per-course {@code autoOrchestratorEnabled} kill switch: a course only
34+
* participates in the pipeline when the instructor has explicitly enabled it. Exam exercises are
35+
* skipped because competency management is scoped to course content only.
36+
* <p>
37+
* The recording is further filtered by the changed-field set carried on the event
38+
* ({@link ExerciseVersionCreatedEvent#changedFields()}): only versions that touched a
39+
* {@link ExerciseVersionService#COMPETENCY_RELEVANT_FIELDS content-bearing field} record into the
40+
* accumulator, so purely administrative edits (dates, points, grading config, …) never burn the
41+
* per-course daily cap on an orchestration that could not change competency mapping.
2842
*/
2943
@Conditional(AtlasEnabled.class)
3044
@Lazy
@@ -37,19 +51,28 @@ public class AutonomousCompetencyExerciseEventListener {
3751

3852
private final FeatureToggleService featureToggleService;
3953

40-
public AutonomousCompetencyExerciseEventListener(ContentChangeAccumulatorService accumulator, FeatureToggleService featureToggleService) {
54+
private final CourseConfigurationRepository courseConfigurationRepository;
55+
56+
public AutonomousCompetencyExerciseEventListener(ContentChangeAccumulatorService accumulator, FeatureToggleService featureToggleService,
57+
CourseConfigurationRepository courseConfigurationRepository) {
4158
this.accumulator = accumulator;
4259
this.featureToggleService = featureToggleService;
60+
this.courseConfigurationRepository = courseConfigurationRepository;
4361
}
4462

4563
/**
4664
* Fires on every {@link ExerciseVersionCreatedEvent} — publishers live in the exercise module,
4765
* so one listener covers every authoring path (programming / text / modeling / quiz / file
48-
* upload). The method is a no-op when the toggle is off, when the exercise is an exam exercise,
49-
* or when any null guard trips; in the success path it merges the exercise id into the
50-
* per-course accumulator for the scheduler to pick up.
66+
* upload). The method is a no-op when the global toggle is off, when the exercise is an exam
67+
* exercise, when any null guard trips, or when the change touched no content-bearing field; in
68+
* the success path it merges the exercise id into the per-course accumulator for the scheduler to
69+
* pick up.
70+
* <p>
71+
* When the owning course has auto-orchestration disabled the method flushes the course's
72+
* accumulator bucket (dropping any ids buffered while it was enabled) and returns without
73+
* recording, so disabling acts as an immediate per-course kill switch.
5174
*
52-
* @param event the just-published event carrying the newly versioned exercise
75+
* @param event the just-published event carrying the newly versioned exercise and its changed fields
5376
*/
5477
@EventListener
5578
@Async
@@ -66,7 +89,25 @@ public void onExerciseVersionCreated(ExerciseVersionCreatedEvent event) {
6689
if (course == null || course.getId() == null) {
6790
return;
6891
}
69-
log.debug("atlas.automatic recorded exercise change courseId={} exerciseId={}", course.getId(), exercise.getId());
70-
accumulator.record(course.getId(), exercise.getId());
92+
long courseId = course.getId();
93+
boolean autoOrchestratorEnabled = courseConfigurationRepository.findAutoOrchestrationConfigByCourseId(courseId)
94+
.map(CourseAutoOrchestrationConfigDTO::autoOrchestratorEnabled).orElse(false);
95+
if (!autoOrchestratorEnabled) {
96+
// Per-course kill switch is off: drop anything buffered while it was on so a later
97+
// re-enable or scheduler tick cannot resurrect stale changes for a disabled course.
98+
accumulator.flush(courseId);
99+
return;
100+
}
101+
// Filter on the changed-field set: only record when the version touched a content-bearing
102+
// field that could affect competency mapping. An empty set (e.g. legacy events) is treated
103+
// as not relevant.
104+
Set<String> changedFields = event.changedFields() == null ? Collections.emptySet() : event.changedFields();
105+
if (Collections.disjoint(changedFields, ExerciseVersionService.COMPETENCY_RELEVANT_FIELDS)) {
106+
log.debug("atlas.automatic skipping exercise change courseId={} exerciseId={}: no competency-relevant field changed (changed={})", courseId, exercise.getId(),
107+
changedFields);
108+
return;
109+
}
110+
log.debug("atlas.automatic recorded exercise change courseId={} exerciseId={}", courseId, exercise.getId());
111+
accumulator.record(courseId, exercise.getId());
71112
}
72113
}

0 commit comments

Comments
 (0)