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[]; |     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 { | ||||||
|  |  | ||||||
|  | @ -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))); | ||||||
|     }, |     }, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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 { 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. | ||||||
|  |  | ||||||
|  | @ -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, | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|  | @ -1 +0,0 @@ | ||||||
| type PointOfView = { type: 'student'; student: Student } | { type: 'group'; group: Group }; |  | ||||||
		Reference in a new issue
	
	 Gerald Schmittinger
						Gerald Schmittinger