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 };
|
|
Loading…
Add table
Add a link
Reference in a new issue