Merge remote-tracking branch 'origin/feature/own-learning-objects' into feature/own-learning-objects

# Conflicts:
#	backend/src/services/learning-objects/database-learning-object-provider.ts
#	backend/tests/services/learning-objects/database-learning-object-provider.test.ts
#	backend/tests/test-utils/expectations.ts
This commit is contained in:
Gerald Schmittinger 2025-03-11 04:45:09 +01:00
commit 9f28e4ed17
84 changed files with 874 additions and 1048 deletions

View file

@ -1,19 +1,11 @@
import {LearningPathProvider} from "./learning-path-provider";
import {
FilteredLearningObject,
LearningObjectNode,
LearningPath,
LearningPathResponse,
Transition
} from "../../interfaces/learning-content";
import {
LearningPath as LearningPathEntity
} from "../../entities/content/learning-path.entity"
import {getLearningPathRepository} from "../../data/repositories";
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 { LearningPathProvider } from './learning-path-provider';
import { FilteredLearningObject, LearningObjectNode, LearningPath, LearningPathResponse, Transition } from '../../interfaces/learning-content';
import { LearningPath as LearningPathEntity } from '../../entities/content/learning-path.entity';
import { getLearningPathRepository } from '../../data/repositories';
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';
/**
* Fetches the corresponding learning object for each of the nodes and creates a map that maps each node to its
@ -22,22 +14,22 @@ import {LearningPathTransition} from "../../entities/content/learning-path-trans
*/
async function getLearningObjectsForNodes(nodes: LearningPathNode[]): Promise<Map<LearningPathNode, FilteredLearningObject>> {
// Fetching the corresponding learning object for each of the nodes and creating a map that maps each node to
// its corresponding learning object.
// Its corresponding learning object.
const nullableNodesToLearningObjects = new Map<LearningPathNode, FilteredLearningObject | null>(
await Promise.all(
nodes.map(node =>
learningObjectService.getLearningObjectById({
hruid: node.learningObjectHruid,
version: node.version,
language: node.language
}).then(learningObject =>
<[LearningPathNode, FilteredLearningObject | null]>[node, learningObject]
)
nodes.map((node) =>
learningObjectService
.getLearningObjectById({
hruid: node.learningObjectHruid,
version: node.version,
language: node.language,
})
.then((learningObject) => <[LearningPathNode, FilteredLearningObject | null]>[node, learningObject])
)
)
);
if (nullableNodesToLearningObjects.values().some(it => it === null)) {
throw new Error("At least one of the learning objects on this path could not be found.")
if (nullableNodesToLearningObjects.values().some((it) => it === null)) {
throw new Error('At least one of the learning objects on this path could not be found.');
}
return nullableNodesToLearningObjects as Map<LearningPathNode, FilteredLearningObject>;
}
@ -46,19 +38,22 @@ 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> {
const nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject> =
await getLearningObjectsForNodes(learningPath.nodes);
const nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject> = await getLearningObjectsForNodes(learningPath.nodes);
const targetAges =
nodesToLearningObjects.values().flatMap(it => it.targetAges || []).toArray();
const targetAges = nodesToLearningObjects
.values()
.flatMap((it) => it.targetAges || [])
.toArray();
const keywords =
nodesToLearningObjects.values().flatMap(it => it.keywords || []).toArray();
const keywords = nodesToLearningObjects
.values()
.flatMap((it) => it.keywords || [])
.toArray();
const image = learningPath.image ? learningPath.image.toString("base64") : undefined;
const image = learningPath.image ? learningPath.image.toString('base64') : undefined;
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,
hruid: learningPath.hruid,
language: learningPath.language,
@ -71,8 +66,8 @@ async function convertLearningPath(learningPath: LearningPathEntity, order: numb
keywords: keywords.join(' '),
target_ages: targetAges,
max_age: Math.max(...targetAges),
min_age: Math.min(...targetAges)
}
min_age: Math.min(...targetAges),
};
}
/**
@ -80,24 +75,23 @@ async function convertLearningPath(learningPath: LearningPathEntity, order: numb
* learning objects into a list of learning path nodes as they should be represented in the API.
* @param nodesToLearningObjects
*/
function convertNodes(
nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject>
): LearningObjectNode[] {
return nodesToLearningObjects.entries().map((entry) => {
const [node, learningObject] = entry;
return {
_id: learningObject.uuid,
language: learningObject.language,
start_node: node.startNode,
created_at: node.createdAt.toISOString(),
updatedAt: node.updatedAt.toISOString(),
learningobject_hruid: node.learningObjectHruid,
version: learningObject.version,
transitions: node.transitions.map((trans, i) =>
convertTransition(trans, i, nodesToLearningObjects)
)
}
}).toArray();
function convertNodes(nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject>): LearningObjectNode[] {
return nodesToLearningObjects
.entries()
.map((entry) => {
const [node, learningObject] = entry;
return {
_id: learningObject.uuid,
language: learningObject.language,
start_node: node.startNode,
created_at: node.createdAt.toISOString(),
updatedAt: node.updatedAt.toISOString(),
learningobject_hruid: node.learningObjectHruid,
version: learningObject.version,
transitions: node.transitions.map((trans, i) => convertTransition(trans, i, nodesToLearningObjects)),
};
})
.toArray();
}
/**
@ -115,17 +109,17 @@ function convertTransition(
): Transition {
const nextNode = nodesToLearningObjects.get(transition.next);
if (!nextNode) {
throw new Error(`Learning object ${transition.next.learningObjectHruid}/${transition.next.language}/${transition.next.version} not found!`)
throw new Error(`Learning object ${transition.next.learningObjectHruid}/${transition.next.language}/${transition.next.version} not found!`);
} else {
return {
_id: "" + index, // Retained for backwards compatibility. The index uniquely identifies the transition within the learning path.
_id: '' + index, // Retained for backwards compatibility. The index uniquely identifies the transition within the learning path.
default: false, // We don't work with default transitions but retain this for backwards compatibility.
next: {
_id: nextNode._id + index, // Construct a unique ID for the transition for backwards compatibility.
hruid: transition.next.learningObjectHruid,
language: nextNode.language,
version: nextNode.version
}
version: nextNode.version,
},
};
}
}
@ -140,19 +134,15 @@ const databaseLearningPathProvider: LearningPathProvider = {
async fetchLearningPaths(hruids: string[], language: Language, source: string): 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)));
const filteredLearningPaths = await Promise.all(
learningPaths
.filter(learningPath => learningPath !== null)
.map((learningPath, index) => convertLearningPath(learningPath, index))
learningPaths.filter((learningPath) => learningPath !== null).map((learningPath, index) => convertLearningPath(learningPath, index))
);
return {
success: filteredLearningPaths.length > 0,
data: await Promise.all(filteredLearningPaths),
source
source,
};
},
@ -163,12 +153,8 @@ const databaseLearningPathProvider: LearningPathProvider = {
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)));
},
};
export default databaseLearningPathProvider;

View file

@ -1,17 +1,10 @@
import { fetchWithLogging } from '../../util/apiHelper.js';
import { DWENGO_API_BASE } from '../../config.js';
import {
LearningPath,
LearningPathResponse,
} from '../../interfaces/learning-content.js';
import {LearningPathProvider} from "./learning-path-provider";
import { LearningPath, LearningPathResponse } from '../../interfaces/learning-content.js';
import { LearningPathProvider } from './learning-path-provider';
const dwengoApiLearningPathProvider: LearningPathProvider = {
async fetchLearningPaths(
hruids: string[],
language: string,
source: string
): Promise<LearningPathResponse> {
async fetchLearningPaths(hruids: string[], language: string, source: string): Promise<LearningPathResponse> {
if (hruids.length === 0) {
return {
success: false,
@ -24,11 +17,7 @@ const dwengoApiLearningPathProvider: LearningPathProvider = {
const apiUrl = `${DWENGO_API_BASE}/learningPath/getPathsFromIdList`;
const params = { pathIdList: JSON.stringify({ hruids }), language };
const learningPaths = await fetchWithLogging<LearningPath[]>(
apiUrl,
`Learning paths for ${source}`,
{ params }
);
const learningPaths = await fetchWithLogging<LearningPath[]>(apiUrl, `Learning paths for ${source}`, { params });
if (!learningPaths || learningPaths.length === 0) {
console.error(`⚠️ WARNING: No learning paths found for ${source}.`);
@ -46,20 +35,13 @@ const dwengoApiLearningPathProvider: LearningPathProvider = {
data: learningPaths,
};
},
async searchLearningPaths(
query: string,
language: string
): Promise<LearningPath[]> {
async searchLearningPaths(query: string, language: string): Promise<LearningPath[]> {
const apiUrl = `${DWENGO_API_BASE}/learningPath/search`;
const params = { all: query, language };
const searchResults = await fetchWithLogging<LearningPath[]>(
apiUrl,
`Search learning paths with query "${query}"`,
{ params }
);
const searchResults = await fetchWithLogging<LearningPath[]>(apiUrl, `Search learning paths with query "${query}"`, { params });
return searchResults ?? [];
}
},
};
export default dwengoApiLearningPathProvider;

View file

@ -1,21 +1,21 @@
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';
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> {
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
version: node.version,
};
let lastSubmission: Submission | null;
if (pathFor.group) {
@ -23,47 +23,46 @@ async function getLastRelevantSubmission(node: LearningPathNode, pathFor: {stude
} 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!");
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) {
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;
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[]> {
let trajectory: LearningPathNode[] = [];
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];
let currentNode = nodes.filter((it) => it.startNode)[0];
trajectory.push(currentNode);
while (true) {
// At every node, calculate all the possible next transitions.
let lastSubmission = await getLastRelevantSubmission(currentNode, pathFor);
let submitted = lastSubmission === null ? null : JSON.parse(lastSubmission.content);
let possibleTransitions = currentNode.transitions
.filter(it => transitionPossible(it, submitted));
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.
if (possibleTransitions.length === 0) {
// If there are none, the trajectory has ended.
return trajectory;
} else { // Otherwise, take the first possible transition.
currentNode = possibleTransitions[0].node;
trajectory.push(currentNode);
}
} // Otherwise, take the first possible transition.
currentNode = possibleTransitions[0].node;
trajectory.push(currentNode);
}
}
},
};
export default learningPathPersonalizingService;

View file

@ -1,5 +1,5 @@
import {LearningPath, LearningPathResponse} from "../../interfaces/learning-content";
import {Language} from "../../entities/content/language";
import { LearningPath, LearningPathResponse } from '../../interfaces/learning-content';
import { Language } from '../../entities/content/language';
/**
* Generic interface for a service which provides access to learning paths from a data source.

View file

@ -1,14 +1,11 @@
import {
LearningPath,
LearningPathResponse
} from "../../interfaces/learning-content";
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 { LearningPath, LearningPathResponse } from '../../interfaces/learning-content';
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';
const userContentPrefix = getEnvVar(EnvVars.UserContentPrefix);
const allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvider]
const allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvider];
/**
* Service providing access to data about learning paths from the appropriate data source (database or Dwengo-api)
@ -18,18 +15,16 @@ const learningPathService = {
* Fetch the learning paths with the given hruids from the data source.
*/
async fetchLearningPaths(hruids: string[], language: Language, source: string): Promise<LearningPathResponse> {
const userContentHruids = hruids.filter(hruid => hruid.startsWith(userContentPrefix));
const nonUserContentHruids = hruids.filter(hruid => !hruid.startsWith(userContentPrefix));
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);
const nonUserContentLearningPaths = await dwengoApiLearningPathProvider.fetchLearningPaths(nonUserContentHruids, language, source);
return {
data: (userContentLearningPaths.data || []).concat(nonUserContentLearningPaths.data || []),
source: source,
success: userContentLearningPaths.success || nonUserContentLearningPaths.success
success: userContentLearningPaths.success || nonUserContentLearningPaths.success,
};
},
@ -37,13 +32,9 @@ const learningPathService = {
* Search learning paths in the data source using the given search string.
*/
async searchLearningPaths(query: string, language: Language): Promise<LearningPath[]> {
const providerResponses = await Promise.all(
allProviders.map(
provider => provider.searchLearningPaths(query, language)
)
);
const providerResponses = await Promise.all(allProviders.map((provider) => provider.searchLearningPaths(query, language)));
return providerResponses.flat();
}
}
},
};
export default learningPathService;