Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Questions randomly selected from resource pool #11823

Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@ export const QuizExercise = {
type: String,
default: '',
},
assessment_ids: {
type: Array,
default: () => [],
assessmentmetadata: {
type: Object,
default: () => ({ assessment_item_ids: [] }),
},
contentnode: {
type: String,
Expand Down
152 changes: 41 additions & 111 deletions kolibri/plugins/coach/assets/src/composables/useQuizCreation.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { v4 } from 'uuid';
import isEqual from 'lodash/isEqual';
import uniqWith from 'lodash/uniqWith';
import range from 'lodash/range';
import shuffle from 'lodash/shuffle';
import { enhancedQuizManagementStrings } from 'kolibri-common/strings/enhancedQuizManagementStrings';
Expand All @@ -9,10 +8,10 @@ import { ContentNodeKinds } from 'kolibri.coreVue.vuex.constants';
import { ChannelResource, ExamResource } from 'kolibri.resources';
import { validateObject, objectWithDefaults } from 'kolibri.utils.objectSpecs';
import { get, set } from '@vueuse/core';
import { computed, ref, provide, inject } from 'kolibri.lib.vueCompositionApi';
import { computed, ref, watch, provide, inject } from 'kolibri.lib.vueCompositionApi';
import logging from 'kolibri.lib.logging';
// TODO: Probably move this to this file's local dir
import selectQuestions from '../modules/examCreation/selectQuestions.js';
import selectQuestions from '../utils/selectQuestions.js';
import { Quiz, QuizSection, QuizQuestion, QuizExercise } from './quizCreationSpecs.js';

const logger = logging.getLogger(__filename);
Expand All @@ -30,14 +29,6 @@ function validateQuiz(quiz) {
return validateObject(quiz, Quiz);
}

/**
* @param {QuizExercise} o - The resource to check
* @returns {boolean} - True if the resource is a valid QuizExercise
*/
function isExercise(o) {
return o.kind === ContentNodeKinds.EXERCISE;
}

/**
* Composable function presenting primary interface for Quiz Creation
*/
Expand All @@ -46,10 +37,6 @@ export default function useQuizCreation(DEBUG = false) {
// Local state
// -----------

/** @type {ComputedRef<QuizExercise[]>} Currently selected resource_pool
* from the side_panel*/
const _working_resource_pool = ref([]);

/** @type {ref<Quiz>}
* The "source of truth" quiz object from which all reactive properties should derive */
const _quiz = ref(objectWithDefaults({}, Quiz));
Expand Down Expand Up @@ -178,13 +165,8 @@ export default function useQuizCreation(DEBUG = false) {
} else if (question_count > (targetSection.question_count || 0)) {
// If the question_count is being increased, we need to add new questions to the end of the
// questions array
const newQuestions = selectQuestions(
question_count - (targetSection.question_count || 0),
targetSection.resource_pool.map(r => r.content_id),
targetSection.resource_pool.map(r => r.title),
targetSection.resource_pool.map(r => r.questions.map(q => q.question_id)),
get(_quiz).seed
);
const numQuestionsToAdd = question_count - (targetSection.question_count || 0);
const newQuestions = selectRandomQuestionsFromResources(numQuestionsToAdd);
updates.questions = [...targetSection.questions, ...newQuestions];
}
}
Expand All @@ -201,6 +183,23 @@ export default function useQuizCreation(DEBUG = false) {
});
}

/**
* @description Selects random questions from the active section's `resource_pool` - no side
* effects
* @param numQuestions
* @returns {QuizQuestion[]}
*/
function selectRandomQuestionsFromResources(numQuestions) {
const pool = get(activeResourcePool);
return selectQuestions(
numQuestions,
pool.map(r => r.content_id),
pool.map(r => r.title),
pool.map(r => r.assessmentmetadata.assessment_item_ids),
get(_quiz).seed
);
}

/**
* @param {QuizQuestion[]} newQuestions
* @affects _quiz - Updates the active section's `questions` property
Expand Down Expand Up @@ -272,12 +271,16 @@ export default function useQuizCreation(DEBUG = false) {
setActiveSection(newSection.section_id);
}
_fetchChannels();
}

// // Method to initialize the working resource pool
function initializeWorkingResourcePool() {
// Set the value of _working_resource_pool to the resource_pool of the active section
set(_working_resource_pool, get(activeResourcePool));
// Set watcher once we have a section in place
watch(activeResourcePool, (resourcePool, old) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Presumably we initiate this whenever we do, so we might be able to decide more directly when to do this rather than a potentially expensive or unreliable isEqual call?

Not clear that this is actually a problem, so definitely not a blocker.

if (!isEqual(resourcePool, old)) {
updateSection({
section_id: get(_activeSectionId),
questions: selectRandomQuestionsFromResources(get(activeSection).question_count),
});
}
});
}

/**
Expand Down Expand Up @@ -364,11 +367,6 @@ export default function useQuizCreation(DEBUG = false) {
}
}

function resetWorkingResourcePool() {
// Set the WorkingResource to empty array again!
set(_working_resource_pool, []);
}

/**
* @affects _channels - Fetches all channels with exercises and sets them to _channels */
function _fetchChannels() {
Expand All @@ -391,21 +389,6 @@ export default function useQuizCreation(DEBUG = false) {
}

// Utilities
/**
* @params {string} section_id - The section_id whose resource_pool we'll use.
* @returns {QuizQuestion[]}
*/
/*
function _getQuestionsFromSection(section_id) {
const section = get(allSections).find(s => s.section_id === section_id);
if (!section) {
throw new Error(`Section with id ${section_id} not found.`);
}
return get(activeExercisePool).reduce((acc, exercise) => {
return [...acc, ...exercise.questions];
}, []);
}
*/

// Computed properties
/** @type {ComputedRef<Quiz>} The value of _quiz */
Expand All @@ -422,10 +405,13 @@ export default function useQuizCreation(DEBUG = false) {
);
/** @type {ComputedRef<QuizExercise[]>} The active section's `resource_pool` */
const activeResourcePool = computed(() => get(activeSection).resource_pool);
/** @type {ComputedRef<ExerciseResource[]>} The active section's `resource_pool` - that is,
* Exercises from which we will enumerate all
* available questions */
const activeExercisePool = computed(() => get(activeResourcePool).filter(isExercise));
/** @type {ComputedRef<QuizExercise[]>} The active section's `resource_pool` */
const activeResourceMap = computed(() =>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The alternative here would be to store an activeResourceMap as the basic representation, and have Object.values for activeExercisePool if it is ever needed as an array.

Looks like this is never exported, so maybe we should just set it as a map in the first place?

get(activeResourcePool).reduce((acc, resource) => {
acc[resource.content_id] = resource;
return acc;
}, {})
);
/** @type {ComputedRef<QuizQuestion[]>} All questions in the active section's `resource_pool`
* exercises */
const activeQuestionsPool = computed(() => []);
Expand All @@ -440,9 +426,6 @@ export default function useQuizCreation(DEBUG = false) {
/** @type {ComputedRef<Array>} A list of all channels available which have exercises */
const channels = computed(() => get(_channels));

// /** @type {ComputedRef<QuizExercise[]>} The current value of _working_resource_pool */
const workingResourcePool = computed(() => get(_working_resource_pool));

/** Handling the Select All Checkbox
* See: remove/toggleQuestionFromSelection() & selectAllQuestions() for more */

Expand Down Expand Up @@ -480,45 +463,12 @@ export default function useQuizCreation(DEBUG = false) {
}
});

/**
* @param {QuizExercise[]} resources
* @affects _working_resource_pool -- Updates it with the given resources and is ensured to have
* a list of unique resources to avoid unnecessary duplication
*/
function addToWorkingResourcePool(resources = []) {
set(_working_resource_pool, uniqWith([...get(_working_resource_pool), ...resources], isEqual));
}

/**
* @param {QuizExercise} content
* @affects _working_resource_pool - Remove given quiz exercise from _working_resource_pool
*/
function removeFromWorkingResourcePool(content) {
set(
_working_resource_pool,
_working_resource_pool.value.filter(obj => obj.id !== content.id)
);
}

/**
* @param {QuizExercise} content
* Check if the content is present in working_resource_pool
*/
function contentPresentInWorkingResourcePool(content) {
const workingResourceIds = get(workingResourcePool).map(wr => wr.id);
return workingResourceIds.includes(content.id);
}

/** @type {ComputedRef<Boolean>} Whether the select all checkbox should be indeterminate */
const selectAllIsIndeterminate = computed(() => {
return !get(allQuestionsSelected) && !get(noQuestionsSelected);
});

provide('saveQuiz', saveQuiz);
provide('initializeWorkingResourcePool', initializeWorkingResourcePool);
provide('addToWorkingResourcePool', addToWorkingResourcePool);
provide('removeFromWorkingResourcePool', removeFromWorkingResourcePool);
provide('contentPresentInWorkingResourcePool', contentPresentInWorkingResourcePool);
provide('updateSection', updateSection);
provide('replaceSelectedQuestions', replaceSelectedQuestions);
provide('addSection', addSection);
Expand All @@ -528,15 +478,13 @@ export default function useQuizCreation(DEBUG = false) {
provide('updateQuiz', updateQuiz);
provide('addQuestionToSelection', addQuestionToSelection);
provide('removeQuestionFromSelection', removeQuestionFromSelection);
provide('resetWorkingResourcePool', resetWorkingResourcePool);
provide('channels', channels);
provide('quiz', quiz);
provide('allSections', allSections);
provide('activeSection', activeSection);
provide('inactiveSections', inactiveSections);
provide('activeResourcePool', activeResourcePool);
provide('workingResourcePool', workingResourcePool);
provide('activeExercisePool', activeExercisePool);
provide('activeResourceMap', activeResourceMap);
provide('activeQuestionsPool', activeQuestionsPool);
provide('activeQuestions', activeQuestions);
provide('selectedActiveQuestions', selectedActiveQuestions);
Expand All @@ -548,13 +496,8 @@ export default function useQuizCreation(DEBUG = false) {
return {
// Methods
saveQuiz,
initializeWorkingResourcePool,
removeFromWorkingResourcePool,
addToWorkingResourcePool,
contentPresentInWorkingResourcePool,
updateSection,
replaceSelectedQuestions,
resetWorkingResourcePool,
addSection,
removeSection,
setActiveSection,
Expand All @@ -569,9 +512,8 @@ export default function useQuizCreation(DEBUG = false) {
allSections,
activeSection,
inactiveSections,
workingResourcePool,
activeResourcePool,
activeExercisePool,
activeResourceMap,
activeQuestionsPool,
activeQuestions,
selectedActiveQuestions,
Expand All @@ -594,14 +536,9 @@ export default function useQuizCreation(DEBUG = false) {

export function injectQuizCreation() {
const saveQuiz = inject('saveQuiz');
const initializeWorkingResourcePool = inject('initializeWorkingResourcePool');
const removeFromWorkingResourcePool = inject('removeFromWorkingResourcePool');
const contentPresentInWorkingResourcePool = inject('contentPresentInWorkingResourcePool');
const addToWorkingResourcePool = inject('addToWorkingResourcePool');
const updateSection = inject('updateSection');
const replaceSelectedQuestions = inject('replaceSelectedQuestions');
const addSection = inject('addSection');
const resetWorkingResourcePool = inject('resetWorkingResourcePool');
const removeSection = inject('removeSection');
const setActiveSection = inject('setActiveSection');
const initializeQuiz = inject('initializeQuiz');
Expand All @@ -614,8 +551,7 @@ export function injectQuizCreation() {
const activeSection = inject('activeSection');
const inactiveSections = inject('inactiveSections');
const activeResourcePool = inject('activeResourcePool');
const workingResourcePool = inject('workingResourcePool');
const activeExercisePool = inject('activeExercisePool');
const activeResourceMap = inject('activeResourceMap');
const activeQuestionsPool = inject('activeQuestionsPool');
const activeQuestions = inject('activeQuestions');
const selectedActiveQuestions = inject('selectedActiveQuestions');
Expand All @@ -627,14 +563,9 @@ export function injectQuizCreation() {
return {
// Methods
saveQuiz,
initializeWorkingResourcePool,
addToWorkingResourcePool,
contentPresentInWorkingResourcePool,
removeFromWorkingResourcePool,
deleteActiveSelectedQuestions,
selectAllQuestions,
updateSection,
resetWorkingResourcePool,
replaceSelectedQuestions,
addSection,
removeSection,
Expand All @@ -651,9 +582,8 @@ export function injectQuizCreation() {
allSections,
activeSection,
inactiveSections,
workingResourcePool,
activeResourcePool,
activeExercisePool,
activeResourceMap,
activeQuestionsPool,
activeQuestions,
selectedActiveQuestions,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { ContentNodeKinds } from 'kolibri.coreVue.vuex.constants';
import { PageNames } from '../../constants';
import { MAX_QUESTIONS } from '../../constants/examConstants';
import { createExam } from '../examShared/exams';
import selectQuestions from './selectQuestions';

export function resetExamCreationState(store) {
store.commit('RESET_STATE');
Expand Down Expand Up @@ -216,14 +215,17 @@ export function updateSelectedQuestions(store) {
contentNodes.forEach(exercise => {
exercises[exercise.id] = exercise;
});
// TODO This file needs to be cleaned up when updates to quiz management are complete -- this
// will be removed altogether so just no-op for now is ok
const doNothing = () => null;
const availableExercises = exerciseIds.filter(id => exercises[id]);
const exerciseTitles = availableExercises.map(id => exercises[id].title);
const questionIdArrays = availableExercises.map(
id => assessmentMetaDataState(exercises[id]).assessmentIds
);
store.commit(
'SET_SELECTED_QUESTIONS',
selectQuestions(
doNothing(
store.state.numberOfQuestions,
availableExercises,
exerciseTitles,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,18 @@ const logging = logger.getLogger(__filename);
const getTotalOfQuestions = sumBy(qArray => qArray.length);

/**
* TODO: Move this into the composables directory, clarify typing expectations below
* Choose a an evenly-distributed random selection of questions from exercises. Note that the order
* of the arrays should correspond to each other, ie, exerciseIds[i] should correspond to
* questionIdArrays[i] should correspond to exerciseTitles[i], etc.
*
* Choose a an evenly-distributed random selection of questions from exercises.
* @param {number} numQuestions - target number of questions
* @param {array} exerciseIds - exercise IDs
* @param {array} exerciseTitle - exercise titles
* @param {array} questionIdArrays - arrays of question/assessment IDs
* corresponding to the exercise IDs
* @param {Number} numQuestions - target number of questions
* @param {String[]} exerciseIds - QuizExercise IDs
* @param {String[]} exerciseTitle - QuizExercise titles
* @param {Array[String[]]} questionIdArrays - QuizQuestion (assessment) ID arrays corresponding
* to each exercise by index (ie, questionIdArrays[i] corresponds to exerciseIds[i])
* @param {number} seed - value to seed the random shuffle with
* @return {array} - objects of the form { exercise_id, question_id, title }
*
* @return {QuizQuestion[]}
*/
export default function selectQuestions(
numQuestions,
Expand Down
Loading
Loading