diff --git a/backend/src/interfaces/learning-content.ts b/backend/src/interfaces/learning-content.ts index 970796a2..a558dd9c 100644 --- a/backend/src/interfaces/learning-content.ts +++ b/backend/src/interfaces/learning-content.ts @@ -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 { diff --git a/backend/src/services/learning-paths/database-learning-path-provider.ts b/backend/src/services/learning-paths/database-learning-path-provider.ts index 3b3b49af..d804ad64 100644 --- a/backend/src/services/learning-paths/database-learning-path-provider.ts +++ b/backend/src/services/learning-paths/database-learning-path-provider.ts @@ -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 { +async function convertLearningPath(learningPath: LearningPathEntity, order: number, personalizedFor?: PersonalizationTarget): Promise { const nodesToLearningObjects: Map = 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): LearningObjectNode[] { - return nodesToLearningObjects +async function convertNodes(nodesToLearningObjects: Map, personalizedFor?: PersonalizationTarget): Promise { + 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 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 { + async fetchLearningPaths(hruids: string[], language: Language, source: string, personalizedFor?: PersonalizationTarget): Promise { 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 { + async searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise { 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))); }, }; diff --git a/backend/src/services/learning-paths/learning-path-personalization-util.ts b/backend/src/services/learning-paths/learning-path-personalization-util.ts new file mode 100644 index 00000000..9217fa34 --- /dev/null +++ b/backend/src/services/learning-paths/learning-path-personalization-util.ts @@ -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 { + 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; +} diff --git a/backend/src/services/learning-paths/learning-path-personalizing-service.ts b/backend/src/services/learning-paths/learning-path-personalizing-service.ts deleted file mode 100644 index f204d742..00000000 --- a/backend/src/services/learning-paths/learning-path-personalizing-service.ts +++ /dev/null @@ -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 { - 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 { - 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; diff --git a/backend/src/services/learning-paths/learning-path-provider.ts b/backend/src/services/learning-paths/learning-path-provider.ts index baff35bf..571c231a 100644 --- a/backend/src/services/learning-paths/learning-path-provider.ts +++ b/backend/src/services/learning-paths/learning-path-provider.ts @@ -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; + fetchLearningPaths(hruids: string[], language: Language, source: string, personalizedFor?: PersonalizationTarget): Promise; /** * Search learning paths in the data source using the given search string. diff --git a/backend/src/services/learning-paths/learning-path-service.ts b/backend/src/services/learning-paths/learning-path-service.ts index 23f0d9ba..c204872c 100644 --- a/backend/src/services/learning-paths/learning-path-service.ts +++ b/backend/src/services/learning-paths/learning-path-service.ts @@ -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 { + async fetchLearningPaths(hruids: string[], language: Language, source: string, personalizedFor?: PersonalizationTarget): Promise { 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, }; diff --git a/backend/src/services/learning-paths/point-of-view.d.ts b/backend/src/services/learning-paths/point-of-view.d.ts deleted file mode 100644 index 14190090..00000000 --- a/backend/src/services/learning-paths/point-of-view.d.ts +++ /dev/null @@ -1 +0,0 @@ -type PointOfView = { type: 'student'; student: Student } | { type: 'group'; group: Group };