Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adaptive learning: Add implementation for grade or bonus goal in learner profile #9699

Open
wants to merge 20 commits into
base: feature/adaptive-learning/learner-profile
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
73b8614
Implement aimForGradeOrBonus
JohannesStoehr Nov 7, 2024
6081d01
Merge branch 'feature/adaptive-learning/learner-profile' into feature…
JohannesStoehr Nov 7, 2024
bd77d23
Fix architecture
JohannesStoehr Nov 7, 2024
253e989
Merge branch 'feature/adaptive-learning/learner-profile' into feature…
JohannesStoehr Nov 8, 2024
5c0142f
Fix exercise ordering
JohannesStoehr Nov 9, 2024
74c8693
Merge branch 'feature/adaptive-learning/learner-profile' into feature…
JohannesStoehr Nov 11, 2024
36de38b
Merge branch 'refs/heads/feature/adaptive-learning/learner-profile' i…
JohannesStoehr Nov 11, 2024
1eac74b
Fix server start up
JohannesStoehr Nov 11, 2024
6a37a21
Fix queries
JohannesStoehr Nov 11, 2024
eadd7bf
Fix tests
JohannesStoehr Nov 11, 2024
cdf4b1f
Merge branch 'feature/adaptive-learning/learner-profile' into feature…
JohannesStoehr Nov 18, 2024
1dfc703
Merge branch 'feature/adaptive-learning/learner-profile' into feature…
JohannesStoehr Nov 19, 2024
d174485
Fix test
JohannesStoehr Nov 19, 2024
d20b87e
Add some more tests
JohannesStoehr Nov 19, 2024
c282358
Merge branch 'feature/adaptive-learning/learner-profile' into feature…
JohannesStoehr Nov 20, 2024
7fdca44
Merge branch 'feature/adaptive-learning/learner-profile' into feature…
JohannesStoehr Nov 21, 2024
6a5f18c
Flo
JohannesStoehr Nov 21, 2024
8ad593c
Fix test compilation
JohannesStoehr Nov 25, 2024
1fb3f3e
Merge branch 'feature/adaptive-learning/learner-profile' into feature…
JohannesStoehr Nov 28, 2024
c17cf3f
Merge branch 'feature/adaptive-learning/learner-profile' into feature…
JohannesStoehr Dec 2, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package de.tum.cit.aet.artemis.atlas.domain.profile;

public enum PreferenceScale {

LOW(1), MEDIUM_LOW(2), MEDIUM(3), MEDIUM_HIGH(4), HIGH(5);
JohannesStoehr marked this conversation as resolved.
Show resolved Hide resolved

private final int value;

PreferenceScale(int value) {
this.value = value;
}

public int getValue() {
return value;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,22 @@ SELECT COUNT (learningPath)
""")
long countLearningPathsOfEnrolledStudentsInCourse(@Param("courseId") long courseId);

@EntityGraph(type = LOAD, attributePaths = { "competencies", "competencies.lectureUnitLinks", "competencies.lectureUnitLinks.lectureUnit", "competencies.exerciseLinks",
"competencies.exerciseLinks.exercise" })
Optional<LearningPath> findWithCompetenciesAndLectureUnitsAndExercisesById(long learningPathId);
@Query("""
SELECT l
FROM LearningPath l
LEFT JOIN FETCH l.competencies c
LEFT JOIN FETCH c.lectureUnitLinks lul
LEFT JOIN FETCH lul.lectureUnit
LEFT JOIN FETCH c.exerciseLinks el
LEFT JOIN FETCH el.exercise
LEFT JOIN FETCH l.user u
LEFT JOIN FETCH u.learnerProfile lp
LEFT JOIN FETCH lp.courseLearnerProfiles clp ON clp.course.id = l.course.id
WHERE lp.id = :learningPathId
""")
Optional<LearningPath> findWithCompetenciesAndLectureUnitsAndExercisesAndLearnerProfile(long learningPathId);

default LearningPath findWithCompetenciesAndLectureUnitsAndExercisesByIdElseThrow(long learningPathId) {
return getValueElseThrow(findWithCompetenciesAndLectureUnitsAndExercisesById(learningPathId), learningPathId);
default LearningPath findWithCompetenciesAndLectureUnitsAndExercisesAndLearnerProfileByIdElseThrow(long learningPathId) {
return getValueElseThrow(findWithCompetenciesAndLectureUnitsAndExercisesAndLearnerProfile(learningPathId), learningPathId);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package de.tum.cit.aet.artemis.atlas.service.learningpath;

import static de.tum.cit.aet.artemis.atlas.domain.profile.PreferenceScale.HIGH;
import static de.tum.cit.aet.artemis.atlas.domain.profile.PreferenceScale.LOW;
import static de.tum.cit.aet.artemis.atlas.domain.profile.PreferenceScale.MEDIUM_HIGH;
import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE;
import static de.tum.cit.aet.artemis.exercise.domain.IncludedInOverallScore.INCLUDED_AS_BONUS;
import static de.tum.cit.aet.artemis.exercise.domain.IncludedInOverallScore.INCLUDED_COMPLETELY;

import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
Expand All @@ -14,6 +19,7 @@
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;

Expand All @@ -31,6 +37,7 @@
import de.tum.cit.aet.artemis.atlas.domain.competency.LearningPath;
import de.tum.cit.aet.artemis.atlas.domain.competency.Prerequisite;
import de.tum.cit.aet.artemis.atlas.domain.competency.RelationType;
import de.tum.cit.aet.artemis.atlas.domain.profile.CourseLearnerProfile;
import de.tum.cit.aet.artemis.atlas.repository.CompetencyProgressRepository;
import de.tum.cit.aet.artemis.atlas.repository.CompetencyRelationRepository;
import de.tum.cit.aet.artemis.atlas.repository.CourseCompetencyRepository;
Expand Down Expand Up @@ -476,6 +483,9 @@ public List<LearningObject> getRecommendedOrderOfLearningObjects(User user, Cour
* @return the recommended ordering of learning objects
*/
public List<LearningObject> getRecommendedOrderOfLearningObjects(User user, CourseCompetency competency, double combinedPriorConfidence) {
var learnerProfile = user.getLearnerProfile();
var courseLearnerProfile = learnerProfile.getCourseLearnerProfiles().stream().findFirst().orElse(new CourseLearnerProfile());

var pendingLectureUnits = competency.getLectureUnitLinks().stream().map(CompetencyLectureUnitLink::getLectureUnit).filter(lectureUnit -> !lectureUnit.isCompletedFor(user))
.toList();
List<LearningObject> recommendedOrder = new ArrayList<>(pendingLectureUnits);
Expand Down Expand Up @@ -504,7 +514,7 @@ public List<LearningObject> getRecommendedOrderOfLearningObjects(User user, Cour
}
final var recommendedExerciseDistribution = getRecommendedExercisePointDistribution(numberOfRequiredExercisePointsToMaster, weightedConfidence);

scheduleExercisesByDistribution(recommendedOrder, recommendedExerciseDistribution, difficultyLevelMap);
scheduleExercisesByDistribution(recommendedOrder, recommendedExerciseDistribution, difficultyLevelMap, courseLearnerProfile);
return recommendedOrder;
}

Expand All @@ -528,30 +538,30 @@ private void scheduleAllExercises(List<LearningObject> recommendedOrder, Map<Dif
* @param difficultyMap a map from difficulty level to a set of corresponding exercises
*/
private void scheduleExercisesByDistribution(List<LearningObject> recommendedOrder, double[] recommendedExercisePointDistribution,
Map<DifficultyLevel, List<Exercise>> difficultyMap) {
Map<DifficultyLevel, List<Exercise>> difficultyMap, CourseLearnerProfile courseLearnerProfile) {
final var easyExercises = new ArrayList<Exercise>();
final var mediumExercises = new ArrayList<Exercise>();
final var hardExercises = new ArrayList<Exercise>();

// choose as many exercises from the correct difficulty level as possible
final var missingEasy = selectExercisesWithDifficulty(difficultyMap, DifficultyLevel.EASY, recommendedExercisePointDistribution[0], easyExercises);
final var missingHard = selectExercisesWithDifficulty(difficultyMap, DifficultyLevel.HARD, recommendedExercisePointDistribution[2], hardExercises);
final var missingEasy = selectExercisesWithDifficulty(difficultyMap, DifficultyLevel.EASY, recommendedExercisePointDistribution[0], easyExercises, courseLearnerProfile);
final var missingHard = selectExercisesWithDifficulty(difficultyMap, DifficultyLevel.HARD, recommendedExercisePointDistribution[2], hardExercises, courseLearnerProfile);

// if there are not sufficiently many exercises per difficulty level, prefer medium difficulty
// case 1: no medium exercises available/medium exercises missing: continue to fill with easy/hard exercises
// case 2: medium exercises available: no medium exercises missing -> missing exercises must be easy/hard -> in both scenarios medium is the closest difficulty level
double mediumExercisePoints = recommendedExercisePointDistribution[1] + missingEasy + missingHard;
double numberOfMissingExercisePoints = selectExercisesWithDifficulty(difficultyMap, DifficultyLevel.MEDIUM, mediumExercisePoints, mediumExercises);
double numberOfMissingExercisePoints = selectExercisesWithDifficulty(difficultyMap, DifficultyLevel.MEDIUM, mediumExercisePoints, mediumExercises, courseLearnerProfile);

// if there are still not sufficiently many medium exercises, choose easy difficulty
// prefer easy to hard exercises to avoid student overload
if (numberOfMissingExercisePoints > 0 && !difficultyMap.get(DifficultyLevel.EASY).isEmpty()) {
numberOfMissingExercisePoints = selectExercisesWithDifficulty(difficultyMap, DifficultyLevel.EASY, numberOfMissingExercisePoints, easyExercises);
numberOfMissingExercisePoints = selectExercisesWithDifficulty(difficultyMap, DifficultyLevel.EASY, numberOfMissingExercisePoints, easyExercises, courseLearnerProfile);
}

// fill remaining slots with hard difficulty
if (numberOfMissingExercisePoints > 0 && !difficultyMap.get(DifficultyLevel.HARD).isEmpty()) {
selectExercisesWithDifficulty(difficultyMap, DifficultyLevel.HARD, numberOfMissingExercisePoints, hardExercises);
selectExercisesWithDifficulty(difficultyMap, DifficultyLevel.HARD, numberOfMissingExercisePoints, hardExercises, courseLearnerProfile);
}

recommendedOrder.addAll(easyExercises);
Expand All @@ -571,15 +581,59 @@ private void scheduleExercisesByDistribution(List<LearningObject> recommendedOrd
* @return amount of points that are missing, if negative the amount of points that are selected too much
*/
private static double selectExercisesWithDifficulty(Map<DifficultyLevel, List<Exercise>> difficultyMap, DifficultyLevel difficulty, double exercisePoints,
List<Exercise> exercises) {
List<Exercise> exercises, CourseLearnerProfile courseLearnerProfile) {
var remainingExercisePoints = new AtomicDouble(exercisePoints);
var selectedExercises = difficultyMap.get(difficulty).stream().takeWhile(exercise -> remainingExercisePoints.getAndAdd(-exercise.getMaxPoints()) >= 0)
.collect(Collectors.toSet());

Comparator<Exercise> exerciseComparator = getExerciseOrderComparator(courseLearnerProfile.getAimForGradeOrBonus());
Predicate<Exercise> exercisePredicate = getExerciseSelectionPredicate(courseLearnerProfile.getAimForGradeOrBonus(), remainingExercisePoints);

var selectedExercises = difficultyMap.get(difficulty).stream().sorted(exerciseComparator).takeWhile(exercisePredicate).toList();

exercises.addAll(selectedExercises);
difficultyMap.get(difficulty).removeAll(selectedExercises);
return remainingExercisePoints.get();
}

/**
* Creates a comparator that orders exercises based on the aim for grade or bonus. In case the student is at least medium low interested the comparator uses the inclusion in
* the score as sorting criterion. Otherwise, the order is unchanged
*
* @param aimForGradeOrBonus the aim for grade or bonus
* @return the comparator that orders the exercise based on the preference
*/
private static Comparator<Exercise> getExerciseOrderComparator(int aimForGradeOrBonus) {
if (aimForGradeOrBonus == LOW.getValue()) {
return Comparator.comparing(ignored -> 0);
}
else {
return Comparator.comparing(exercise -> switch (exercise.getIncludedInOverallScore()) {
case INCLUDED_COMPLETELY -> 0;
case INCLUDED_AS_BONUS -> 1;
case NOT_INCLUDED -> 2;
});
}
}

/**
* Creates a predicate that selects exercises based on the aim for grade or bonus and the remaining exercise points.
*
* @param aimForGradeOrBonus the aim for grade or bonus
* @param remainingExercisePoints the remaining exercise points that should be scheduled
* @return the predicate until when exercises should be selected based on the preference
*/
private static Predicate<Exercise> getExerciseSelectionPredicate(int aimForGradeOrBonus, AtomicDouble remainingExercisePoints) {
Predicate<Exercise> exercisePredicate = exercise -> remainingExercisePoints.getAndAdd(-exercise.getMaxPoints()) >= 0;
if (aimForGradeOrBonus == HIGH.getValue()) {
exercisePredicate = exercisePredicate
.and(exercise -> exercise.getIncludedInOverallScore() == INCLUDED_COMPLETELY || exercise.getIncludedInOverallScore() == INCLUDED_AS_BONUS);
}
else if (aimForGradeOrBonus == MEDIUM_HIGH.getValue()) {
exercisePredicate = exercisePredicate.and(exercise -> exercise.getIncludedInOverallScore() == INCLUDED_COMPLETELY);
}
JohannesStoehr marked this conversation as resolved.
Show resolved Hide resolved

return exercisePredicate;
}

/**
* Computes the average confidence of all prior competencies.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ public LearningPathCompetencyGraphDTO generateLearningPathCompetencyInstructorGr
* @return the navigation overview
*/
public LearningPathNavigationOverviewDTO getLearningPathNavigationOverview(long learningPathId) {
var learningPath = findWithCompetenciesAndReleasedLearningObjectsAndCompletedUsersById(learningPathId);
var learningPath = findWithCompetenciesAndReleasedLearningObjectsAndCompletedUsersAndLearnerProfileById(learningPathId);
if (!userRepository.getUser().equals(learningPath.getUser())) {
throw new AccessForbiddenException("You are not allowed to access this learning path");
}
Expand All @@ -416,8 +416,8 @@ public LearningPathNavigationOverviewDTO getLearningPathNavigationOverview(long
* @param learningPathId the id of the learning path to fetch
* @return the learning path with fetched data
*/
public LearningPath findWithCompetenciesAndReleasedLearningObjectsAndCompletedUsersById(long learningPathId) {
LearningPath learningPath = learningPathRepository.findWithCompetenciesAndLectureUnitsAndExercisesByIdElseThrow(learningPathId);
public LearningPath findWithCompetenciesAndReleasedLearningObjectsAndCompletedUsersAndLearnerProfileById(long learningPathId) {
LearningPath learningPath = learningPathRepository.findWithCompetenciesAndLectureUnitsAndExercisesAndLearnerProfileByIdElseThrow(learningPathId);

// Remove exercises that are not visible to students
learningPath.getCompetencies().forEach(competency -> competency
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ public ResponseEntity<LearningPathNavigationDTO> getRelativeLearningPathNavigati
@RequestParam LearningObjectType learningObjectType, @RequestParam long competencyId) {
log.debug("REST request to get navigation for learning path with id: {} relative to learning object with id: {} and type: {} in competency with id: {}", learningPathId,
learningObjectId, learningObjectType, competencyId);
var learningPath = learningPathService.findWithCompetenciesAndReleasedLearningObjectsAndCompletedUsersById(learningPathId);
var learningPath = learningPathService.findWithCompetenciesAndReleasedLearningObjectsAndCompletedUsersAndLearnerProfileById(learningPathId);
checkLearningPathAccessElseThrow(Optional.empty(), learningPath, Optional.empty());
return ResponseEntity.ok(learningPathNavigationService.getNavigationRelativeToLearningObject(learningPath, learningObjectId, learningObjectType, competencyId));
}
Expand All @@ -248,7 +248,7 @@ public ResponseEntity<LearningPathNavigationDTO> getRelativeLearningPathNavigati
@EnforceAtLeastStudent
public ResponseEntity<LearningPathNavigationDTO> getLearningPathNavigation(@PathVariable long learningPathId) {
log.debug("REST request to get navigation for learning path with id: {}", learningPathId);
var learningPath = learningPathService.findWithCompetenciesAndReleasedLearningObjectsAndCompletedUsersById(learningPathId);
var learningPath = learningPathService.findWithCompetenciesAndReleasedLearningObjectsAndCompletedUsersAndLearnerProfileById(learningPathId);
checkLearningPathAccessElseThrow(Optional.empty(), learningPath, Optional.empty());
return ResponseEntity.ok(learningPathNavigationService.getNavigation(learningPath));
}
Expand Down Expand Up @@ -341,7 +341,7 @@ public ResponseEntity<Set<CompetencyProgressForLearningPathDTO>> getCompetencyPr
@EnforceAtLeastStudent
public ResponseEntity<List<CompetencyNameDTO>> getCompetencyOrderForLearningPath(@PathVariable long learningPathId) {
log.debug("REST request to get competency order for learning path: {}", learningPathId);
final var learningPath = learningPathService.findWithCompetenciesAndReleasedLearningObjectsAndCompletedUsersById(learningPathId);
final var learningPath = learningPathService.findWithCompetenciesAndReleasedLearningObjectsAndCompletedUsersAndLearnerProfileById(learningPathId);

checkLearningPathAccessElseThrow(Optional.of(learningPath.getCourse()), learningPath, Optional.empty());

Expand All @@ -364,7 +364,7 @@ public ResponseEntity<List<CompetencyNameDTO>> getCompetencyOrderForLearningPath
public ResponseEntity<List<LearningPathNavigationObjectDTO>> getLearningObjectsForCompetency(@PathVariable long learningPathId, @PathVariable long competencyId) {
log.debug("REST request to get learning objects for competency: {} in learning path: {}", competencyId, learningPathId);
final var learningPath = learningPathRepository.findWithEagerCourseAndCompetenciesByIdElseThrow(learningPathId);
final var user = userRepository.getUserWithGroupsAndAuthorities();
final var user = userRepository.getUserWithGroupsAndAuthoritiesAndLearnerProfile(learningPath.getCourse().getId());

checkLearningPathAccessElseThrow(Optional.of(learningPath.getCourse()), learningPath, Optional.of(user));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,18 @@ public interface UserRepository extends ArtemisJpaRepository<User, Long>, JpaSpe
@EntityGraph(type = LOAD, attributePaths = { "groups", "authorities" })
Optional<User> findOneWithGroupsAndAuthoritiesByLogin(String login);

@EntityGraph(type = LOAD, attributePaths = { "groups", "authorities" })
@Query("""
SELECT u
FROM User u
LEFT JOIN FETCH u.groups
LEFT JOIN FETCH u.authorities
LEFT JOIN FETCH u.learnerProfile lp
LEFT JOIN FETCH lp.courseLearnerProfiles clp ON clp.course.id = :courseId
WHERE u.login = :login
""")
Optional<User> findOneWithGroupsAndAuthoritiesAndLearnerProfileByLogin(@Param("login") String login, @Param("courseId") long courseId);

@EntityGraph(type = LOAD, attributePaths = { "groups", "authorities" })
Optional<User> findOneWithGroupsAndAuthoritiesByEmail(String email);

Expand Down Expand Up @@ -901,6 +913,18 @@ default User getUserWithGroupsAndAuthorities() {
return getValueElseThrow(findOneWithGroupsAndAuthoritiesByLogin(currentUserLogin));
}

/**
* Get user with user groups and authorities of currently logged-in user
*
* @param courseId the id of the course for which to load the user and the course learner profile
* @return currently logged-in user
*/
@NotNull
default User getUserWithGroupsAndAuthoritiesAndLearnerProfile(long courseId) {
String currentUserLogin = getCurrentUserLogin();
return getValueElseThrow(findOneWithGroupsAndAuthoritiesAndLearnerProfileByLogin(currentUserLogin, courseId));
}

/**
* Get user with user groups, authorities and organizations of currently logged-in user
*
Expand Down
Loading