feat(backend): Added support for customized learning paths to the database learning path provider.
This commit is contained in:
		
							parent
							
								
									466b9b8d17
								
							
						
					
					
						commit
						a69e2625af
					
				
					 7 changed files with 84 additions and 86 deletions
				
			
		|  | @ -26,6 +26,7 @@ export interface LearningObjectNode { | |||
|     transitions: Transition[]; | ||||
|     created_at: 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 { | ||||
|  |  | |||
|  | @ -6,6 +6,11 @@ import { Language } from '../../entities/content/language'; | |||
| import learningObjectService from '../learning-objects/learning-object-service'; | ||||
| import { LearningPathNode } from '../../entities/content/learning-path-node.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 | ||||
|  | @ -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. | ||||
|  */ | ||||
| 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 targetAges = nodesToLearningObjects | ||||
|  | @ -52,6 +57,8 @@ async function convertLearningPath(learningPath: LearningPathEntity, order: numb | |||
| 
 | ||||
|     const image = learningPath.image ? learningPath.image.toString('base64') : undefined; | ||||
| 
 | ||||
|     const convertedNodes = await convertNodes(nodesToLearningObjects, personalizedFor); | ||||
| 
 | ||||
|     return { | ||||
|         _id: `${learningPath.hruid}/${learningPath.language}`, // For backwards compatibility with the original Dwengo API.
 | ||||
|         __order: order, | ||||
|  | @ -60,9 +67,9 @@ async function convertLearningPath(learningPath: LearningPathEntity, order: numb | |||
|         description: learningPath.description, | ||||
|         image: image, | ||||
|         title: learningPath.title, | ||||
|         nodes: convertNodes(nodesToLearningObjects), | ||||
|         nodes: convertedNodes, | ||||
|         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(' '), | ||||
|         target_ages: 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 | ||||
|  * learning objects into a list of learning path nodes as they should be represented in the API. | ||||
|  * @param nodesToLearningObjects | ||||
|  * @param personalizedFor | ||||
|  */ | ||||
| function convertNodes(nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject>): LearningObjectNode[] { | ||||
|     return nodesToLearningObjects | ||||
| async function convertNodes(nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject>, personalizedFor?: PersonalizationTarget): Promise<LearningObjectNode[]> { | ||||
|     const nodesPromise = nodesToLearningObjects | ||||
|         .entries() | ||||
|         .map((entry) => { | ||||
|         .map(async(entry) => { | ||||
|             const [node, learningObject] = entry; | ||||
|             const lastSubmission = personalizedFor ? await getLastSubmissionForCustomizationTarget(node, personalizedFor) : null; | ||||
|             return { | ||||
|                 _id: learningObject.uuid, | ||||
|                 language: learningObject.language, | ||||
|  | @ -88,10 +97,13 @@ function convertNodes(nodesToLearningObjects: Map<LearningPathNode, FilteredLear | |||
|                 updatedAt: node.updatedAt.toISOString(), | ||||
|                 learningobject_hruid: node.learningObjectHruid, | ||||
|                 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(); | ||||
|     return await Promise.all(nodesPromise); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  | @ -131,12 +143,16 @@ const databaseLearningPathProvider: LearningPathProvider = { | |||
|     /** | ||||
|      * 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 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( | ||||
|             learningPaths.filter((learningPath) => learningPath !== null).map((learningPath, index) => convertLearningPath(learningPath, index)) | ||||
|             learningPaths.map((learningPath, index) => convertLearningPath(learningPath, index, personalizedFor)) | ||||
|         ); | ||||
| 
 | ||||
|         return { | ||||
|  | @ -149,11 +165,11 @@ const databaseLearningPathProvider: LearningPathProvider = { | |||
|     /** | ||||
|      * 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 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))); | ||||
|     }, | ||||
| }; | ||||
| 
 | ||||
|  |  | |||
|  | @ -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; | ||||
| } | ||||
|  | @ -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; | ||||
|  | @ -1,5 +1,6 @@ | |||
| import { LearningPath, LearningPathResponse } from '../../interfaces/learning-content'; | ||||
| 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. | ||||
|  | @ -8,7 +9,7 @@ export interface LearningPathProvider { | |||
|     /** | ||||
|      * 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. | ||||
|  |  | |||
|  | @ -3,6 +3,7 @@ import dwengoApiLearningPathProvider from './dwengo-api-learning-path-provider'; | |||
| import databaseLearningPathProvider from './database-learning-path-provider'; | ||||
| import { EnvVars, getEnvVar } from '../../util/envvars'; | ||||
| import { Language } from '../../entities/content/language'; | ||||
| import {PersonalizationTarget} from "./learning-path-personalizing-service"; | ||||
| 
 | ||||
| const userContentPrefix = getEnvVar(EnvVars.UserContentPrefix); | ||||
| const allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvider]; | ||||
|  | @ -13,16 +14,22 @@ const allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvide | |||
| const learningPathService = { | ||||
|     /** | ||||
|      * 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 nonUserContentHruids = hruids.filter((hruid) => !hruid.startsWith(userContentPrefix)); | ||||
| 
 | ||||
|         const userContentLearningPaths = await databaseLearningPathProvider.fetchLearningPaths(userContentHruids, language, source); | ||||
|         const nonUserContentLearningPaths = await dwengoApiLearningPathProvider.fetchLearningPaths(nonUserContentHruids, language, source); | ||||
|         const userContentLearningPaths = await databaseLearningPathProvider.fetchLearningPaths(userContentHruids, language, source, personalizedFor); | ||||
|         const nonUserContentLearningPaths = await dwengoApiLearningPathProvider.fetchLearningPaths(nonUserContentHruids, language, source, personalizedFor); | ||||
| 
 | ||||
|         let result = (userContentLearningPaths.data || []).concat(nonUserContentLearningPaths.data || []); | ||||
| 
 | ||||
|         return { | ||||
|             data: (userContentLearningPaths.data || []).concat(nonUserContentLearningPaths.data || []), | ||||
|             data: result, | ||||
|             source: source, | ||||
|             success: userContentLearningPaths.success || nonUserContentLearningPaths.success, | ||||
|         }; | ||||
|  |  | |||
|  | @ -1 +0,0 @@ | |||
| type PointOfView = { type: 'student'; student: Student } | { type: 'group'; group: Group }; | ||||
		Reference in a new issue
	
	 Gerald Schmittinger
						Gerald Schmittinger