feat(backend): Added support for customized learning paths to the database learning path provider.

This commit is contained in:
Gerald Schmittinger 2025-03-11 06:13:29 +01:00
parent 466b9b8d17
commit a69e2625af
7 changed files with 84 additions and 86 deletions

View file

@ -26,6 +26,7 @@ export interface LearningObjectNode {
transitions: Transition[]; transitions: Transition[];
created_at: string; created_at: string;
updatedAt: string; updatedAt: string;
done?: boolean; // true if a submission exists for this node by the user for whom the learning path is customized.
} }
export interface LearningPath { export interface LearningPath {

View file

@ -6,6 +6,11 @@ import { Language } from '../../entities/content/language';
import learningObjectService from '../learning-objects/learning-object-service'; import learningObjectService from '../learning-objects/learning-object-service';
import { LearningPathNode } from '../../entities/content/learning-path-node.entity'; import { LearningPathNode } from '../../entities/content/learning-path-node.entity';
import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity'; import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity';
import {
getLastSubmissionForCustomizationTarget,
isTransitionPossible,
PersonalizationTarget
} from "./learning-path-personalization-util";
/** /**
* Fetches the corresponding learning object for each of the nodes and creates a map that maps each node to its * Fetches the corresponding learning object for each of the nodes and creates a map that maps each node to its
@ -37,7 +42,7 @@ async function getLearningObjectsForNodes(nodes: LearningPathNode[]): Promise<Ma
/** /**
* Convert the given learning path entity to an object which conforms to the learning path content. * Convert the given learning path entity to an object which conforms to the learning path content.
*/ */
async function convertLearningPath(learningPath: LearningPathEntity, order: number): Promise<LearningPath> { async function convertLearningPath(learningPath: LearningPathEntity, order: number, personalizedFor?: PersonalizationTarget): Promise<LearningPath> {
const nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject> = await getLearningObjectsForNodes(learningPath.nodes); const nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject> = await getLearningObjectsForNodes(learningPath.nodes);
const targetAges = nodesToLearningObjects const targetAges = nodesToLearningObjects
@ -52,6 +57,8 @@ async function convertLearningPath(learningPath: LearningPathEntity, order: numb
const image = learningPath.image ? learningPath.image.toString('base64') : undefined; const image = learningPath.image ? learningPath.image.toString('base64') : undefined;
const convertedNodes = await convertNodes(nodesToLearningObjects, personalizedFor);
return { return {
_id: `${learningPath.hruid}/${learningPath.language}`, // For backwards compatibility with the original Dwengo API. _id: `${learningPath.hruid}/${learningPath.language}`, // For backwards compatibility with the original Dwengo API.
__order: order, __order: order,
@ -60,9 +67,9 @@ async function convertLearningPath(learningPath: LearningPathEntity, order: numb
description: learningPath.description, description: learningPath.description,
image: image, image: image,
title: learningPath.title, title: learningPath.title,
nodes: convertNodes(nodesToLearningObjects), nodes: convertedNodes,
num_nodes: learningPath.nodes.length, num_nodes: learningPath.nodes.length,
num_nodes_left: learningPath.nodes.length, // TODO: Adjust when submissions are added. num_nodes_left: convertedNodes.filter(it => !it.done).length,
keywords: keywords.join(' '), keywords: keywords.join(' '),
target_ages: targetAges, target_ages: targetAges,
max_age: Math.max(...targetAges), max_age: Math.max(...targetAges),
@ -74,12 +81,14 @@ async function convertLearningPath(learningPath: LearningPathEntity, order: numb
* Helper function converting pairs of learning path nodes (as represented in the database) and the corresponding * Helper function converting pairs of learning path nodes (as represented in the database) and the corresponding
* learning objects into a list of learning path nodes as they should be represented in the API. * learning objects into a list of learning path nodes as they should be represented in the API.
* @param nodesToLearningObjects * @param nodesToLearningObjects
* @param personalizedFor
*/ */
function convertNodes(nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject>): LearningObjectNode[] { async function convertNodes(nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject>, personalizedFor?: PersonalizationTarget): Promise<LearningObjectNode[]> {
return nodesToLearningObjects const nodesPromise = nodesToLearningObjects
.entries() .entries()
.map((entry) => { .map(async(entry) => {
const [node, learningObject] = entry; const [node, learningObject] = entry;
const lastSubmission = personalizedFor ? await getLastSubmissionForCustomizationTarget(node, personalizedFor) : null;
return { return {
_id: learningObject.uuid, _id: learningObject.uuid,
language: learningObject.language, language: learningObject.language,
@ -88,10 +97,13 @@ function convertNodes(nodesToLearningObjects: Map<LearningPathNode, FilteredLear
updatedAt: node.updatedAt.toISOString(), updatedAt: node.updatedAt.toISOString(),
learningobject_hruid: node.learningObjectHruid, learningobject_hruid: node.learningObjectHruid,
version: learningObject.version, version: learningObject.version,
transitions: node.transitions.map((trans, i) => convertTransition(trans, i, nodesToLearningObjects)), transitions: node.transitions
.filter(trans => !personalizedFor || isTransitionPossible(trans, lastSubmission)) // If we want a personalized learning path, remove all transitions that aren't possible.
.map((trans, i) => convertTransition(trans, i, nodesToLearningObjects)), // then convert all the transition
}; };
}) })
.toArray(); .toArray();
return await Promise.all(nodesPromise);
} }
/** /**
@ -131,12 +143,16 @@ const databaseLearningPathProvider: LearningPathProvider = {
/** /**
* Fetch the learning paths with the given hruids from the database. * Fetch the learning paths with the given hruids from the database.
*/ */
async fetchLearningPaths(hruids: string[], language: Language, source: string): Promise<LearningPathResponse> { async fetchLearningPaths(hruids: string[], language: Language, source: string, personalizedFor?: PersonalizationTarget): Promise<LearningPathResponse> {
const learningPathRepo = getLearningPathRepository(); const learningPathRepo = getLearningPathRepository();
const learningPaths = await Promise.all(hruids.map((hruid) => learningPathRepo.findByHruidAndLanguage(hruid, language))); const learningPaths = (
await Promise.all(
hruids.map((hruid) => learningPathRepo.findByHruidAndLanguage(hruid, language))
)
).filter((learningPath) => learningPath !== null);
const filteredLearningPaths = await Promise.all( const filteredLearningPaths = await Promise.all(
learningPaths.filter((learningPath) => learningPath !== null).map((learningPath, index) => convertLearningPath(learningPath, index)) learningPaths.map((learningPath, index) => convertLearningPath(learningPath, index, personalizedFor))
); );
return { return {
@ -149,11 +165,11 @@ const databaseLearningPathProvider: LearningPathProvider = {
/** /**
* Search learning paths in the database using the given search string. * Search learning paths in the database using the given search string.
*/ */
async searchLearningPaths(query: string, language: Language): Promise<LearningPath[]> { async searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise<LearningPath[]> {
const learningPathRepo = getLearningPathRepository(); const learningPathRepo = getLearningPathRepository();
const searchResults = await learningPathRepo.findByQueryStringAndLanguage(query, language); const searchResults = await learningPathRepo.findByQueryStringAndLanguage(query, language);
return await Promise.all(searchResults.map((result, index) => convertLearningPath(result, index))); return await Promise.all(searchResults.map((result, index) => convertLearningPath(result, index, personalizedFor)));
}, },
}; };

View file

@ -0,0 +1,42 @@
import {LearningPathNode} from "../../entities/content/learning-path-node.entity";
import {Student} from "../../entities/users/student.entity";
import {Group} from "../../entities/assignments/group.entity";
import {Submission} from "../../entities/assignments/submission.entity";
import {getSubmissionRepository} from "../../data/repositories";
import {LearningObjectIdentifier} from "../../entities/content/learning-object-identifier";
import {LearningPathTransition} from "../../entities/content/learning-path-transition.entity";
import {JSONPath} from "jsonpath-plus";
export type PersonalizationTarget = { type: 'student'; student: Student } | { type: 'group'; group: Group };
/**
* Returns the last submission for the learning object associated with the given node and for the student or group
*/
export async function getLastSubmissionForCustomizationTarget(node: LearningPathNode, pathFor: PersonalizationTarget): Promise<Submission | null> {
const submissionRepo = getSubmissionRepository();
const learningObjectId: LearningObjectIdentifier = {
hruid: node.learningObjectHruid,
language: node.language,
version: node.version,
};
if (pathFor.type === 'group') {
return await submissionRepo.findMostRecentSubmissionForGroup(learningObjectId, pathFor.group);
} else {
return await submissionRepo.findMostRecentSubmissionForStudent(learningObjectId, pathFor.student);
}
}
/**
* Checks whether the condition of the given transaction is fulfilled by the given submission.
* @param transition
* @param submitted
*/
export function isTransitionPossible(transition: LearningPathTransition, submitted: object | null): boolean {
if (transition.condition === 'true' || !transition.condition) {
return true; // If the transition is unconditional, we can go on.
}
if (submitted === null) {
return false; // If the transition is not unconditional and there was no submission, the transition is not possible.
}
return JSONPath({ path: transition.condition, json: submitted }).length === 0;
}

View file

@ -1,68 +0,0 @@
import { Student } from '../../entities/users/student.entity';
import { getSubmissionRepository } from '../../data/repositories';
import { Group } from '../../entities/assignments/group.entity';
import { Submission } from '../../entities/assignments/submission.entity';
import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier';
import { LearningPathNode } from '../../entities/content/learning-path-node.entity';
import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity';
import { JSONPath } from 'jsonpath-plus';
/**
* Returns the last submission for the learning object associated with the given node and for the student or group
*/
async function getLastRelevantSubmission(node: LearningPathNode, pathFor: { student?: Student; group?: Group }): Promise<Submission | null> {
const submissionRepo = getSubmissionRepository();
const learningObjectId: LearningObjectIdentifier = {
hruid: node.learningObjectHruid,
language: node.language,
version: node.version,
};
let lastSubmission: Submission | null;
if (pathFor.group) {
lastSubmission = await submissionRepo.findMostRecentSubmissionForGroup(learningObjectId, pathFor.group);
} else if (pathFor.student) {
lastSubmission = await submissionRepo.findMostRecentSubmissionForStudent(learningObjectId, pathFor.student);
} else {
throw new Error('The path must either be created for a certain group or for a certain student!');
}
return lastSubmission;
}
function transitionPossible(transition: LearningPathTransition, submitted: object | null): boolean {
if (transition.condition === 'true' || !transition.condition) {
return true; // If the transition is unconditional, we can go on.
}
if (submitted === null) {
return false; // If the transition is not unconditional and there was no submission, the transition is not possible.
}
return JSONPath({ path: transition.condition, json: submitted }).length === 0;
}
/**
* Service to create individual trajectories from learning paths based on the submissions of the student or group.
*/
const learningPathPersonalizingService = {
async calculatePersonalizedTrajectory(nodes: LearningPathNode[], pathFor: { student?: Student; group?: Group }): Promise<LearningPathNode[]> {
const trajectory: LearningPathNode[] = [];
// Always start with the start node.
let currentNode = nodes.filter((it) => it.startNode)[0];
trajectory.push(currentNode);
while (true) {
// At every node, calculate all the possible next transitions.
const lastSubmission = await getLastRelevantSubmission(currentNode, pathFor);
const submitted = lastSubmission === null ? null : JSON.parse(lastSubmission.content);
const possibleTransitions = currentNode.transitions.filter((it) => transitionPossible(it, submitted));
if (possibleTransitions.length === 0) {
// If there are none, the trajectory has ended.
return trajectory;
} // Otherwise, take the first possible transition.
currentNode = possibleTransitions[0].node;
trajectory.push(currentNode);
}
},
};
export default learningPathPersonalizingService;

View file

@ -1,5 +1,6 @@
import { LearningPath, LearningPathResponse } from '../../interfaces/learning-content'; import { LearningPath, LearningPathResponse } from '../../interfaces/learning-content';
import { Language } from '../../entities/content/language'; import { Language } from '../../entities/content/language';
import {PersonalizationTarget} from "./learning-path-personalizing-service";
/** /**
* Generic interface for a service which provides access to learning paths from a data source. * Generic interface for a service which provides access to learning paths from a data source.
@ -8,7 +9,7 @@ export interface LearningPathProvider {
/** /**
* Fetch the learning paths with the given hruids from the data source. * Fetch the learning paths with the given hruids from the data source.
*/ */
fetchLearningPaths(hruids: string[], language: Language, source: string): Promise<LearningPathResponse>; fetchLearningPaths(hruids: string[], language: Language, source: string, personalizedFor?: PersonalizationTarget): Promise<LearningPathResponse>;
/** /**
* Search learning paths in the data source using the given search string. * Search learning paths in the data source using the given search string.

View file

@ -3,6 +3,7 @@ import dwengoApiLearningPathProvider from './dwengo-api-learning-path-provider';
import databaseLearningPathProvider from './database-learning-path-provider'; import databaseLearningPathProvider from './database-learning-path-provider';
import { EnvVars, getEnvVar } from '../../util/envvars'; import { EnvVars, getEnvVar } from '../../util/envvars';
import { Language } from '../../entities/content/language'; import { Language } from '../../entities/content/language';
import {PersonalizationTarget} from "./learning-path-personalizing-service";
const userContentPrefix = getEnvVar(EnvVars.UserContentPrefix); const userContentPrefix = getEnvVar(EnvVars.UserContentPrefix);
const allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvider]; const allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvider];
@ -13,16 +14,22 @@ const allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvide
const learningPathService = { const learningPathService = {
/** /**
* Fetch the learning paths with the given hruids from the data source. * Fetch the learning paths with the given hruids from the data source.
* @param hruids For each of the hruids, the learning path will be fetched.
* @param language This is the language each of the learning paths will use.
* @param source
* @param personalizedFor If this is set, a learning path personalized for the given group or student will be returned.
*/ */
async fetchLearningPaths(hruids: string[], language: Language, source: string): Promise<LearningPathResponse> { async fetchLearningPaths(hruids: string[], language: Language, source: string, personalizedFor?: PersonalizationTarget): Promise<LearningPathResponse> {
const userContentHruids = hruids.filter((hruid) => hruid.startsWith(userContentPrefix)); const userContentHruids = hruids.filter((hruid) => hruid.startsWith(userContentPrefix));
const nonUserContentHruids = hruids.filter((hruid) => !hruid.startsWith(userContentPrefix)); const nonUserContentHruids = hruids.filter((hruid) => !hruid.startsWith(userContentPrefix));
const userContentLearningPaths = await databaseLearningPathProvider.fetchLearningPaths(userContentHruids, language, source); const userContentLearningPaths = await databaseLearningPathProvider.fetchLearningPaths(userContentHruids, language, source, personalizedFor);
const nonUserContentLearningPaths = await dwengoApiLearningPathProvider.fetchLearningPaths(nonUserContentHruids, language, source); const nonUserContentLearningPaths = await dwengoApiLearningPathProvider.fetchLearningPaths(nonUserContentHruids, language, source, personalizedFor);
let result = (userContentLearningPaths.data || []).concat(nonUserContentLearningPaths.data || []);
return { return {
data: (userContentLearningPaths.data || []).concat(nonUserContentLearningPaths.data || []), data: result,
source: source, source: source,
success: userContentLearningPaths.success || nonUserContentLearningPaths.success, success: userContentLearningPaths.success || nonUserContentLearningPaths.success,
}; };

View file

@ -1 +0,0 @@
type PointOfView = { type: 'student'; student: Student } | { type: 'group'; group: Group };