Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions client/src/lib/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,9 @@ export const BLOOM_LEVELS = [
// Mirrors MAX_MATERIALS_PER_OBJECTIVE in src/constants/app-constants.js.
// The server rejects writes above this; the UI stops the instructor first.
export const MAX_MATERIALS_PER_OBJECTIVE = 3;

// Mirrors MAX_QUESTIONS_PER_OBJECTIVE in src/constants/app-constants.js — see
// there for why this is the only cap. It bounds one GRANULAR objective's total.
// The server clamps to it; the steppers disable at it so the instructor sees the
// limit rather than having it silently applied.
export const MAX_QUESTIONS_PER_OBJECTIVE = 20;
125 changes: 125 additions & 0 deletions client/src/lib/questionTypes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// The question-type breakdown is the single source of truth for how many
// questions a granular objective generates, and for which Bloom levels it
// covers. Every derivation lives here so the card, the chip badges, the type
// panel, and the generation request cannot disagree about the same array —
// they previously did, one reading it with find() and the rest with reduce().
//
// Shape: [{ bloomLevel, questionType, count }]

import {
QUESTION_TYPES,
DEFAULT_BLOOM_TYPE_PREFERENCES,
MAX_QUESTIONS_PER_OBJECTIVE,
} from "./constants";

/** Total questions across every Bloom level and type. */
export function totalQuestions(questionTypes) {
return (questionTypes || []).reduce((sum, qt) => sum + (qt.count || 0), 0);
}

/** Total questions for one Bloom level, across all its types. */
export function levelTotal(questionTypes, bloomLevel) {
return (questionTypes || [])
.filter((qt) => qt.bloomLevel === bloomLevel)
.reduce((sum, qt) => sum + (qt.count || 0), 0);
}

/** Questions for one (level, type) pair. Sums rather than taking the first
* match, so a duplicated pair agrees with the totals above. */
export function pairCount(questionTypes, bloomLevel, questionType) {
return (questionTypes || [])
.filter((qt) => qt.bloomLevel === bloomLevel && qt.questionType === questionType)
.reduce((sum, qt) => sum + (qt.count || 0), 0);
}

/**
* The Bloom levels this objective covers. Selection is derived, never stored
* separately: a level is selected exactly when it has a question type with a
* count above zero. Zeroing a level's last type therefore un-checks it, and a
* selected level with no way to generate anything cannot exist.
*/
export function selectedBloomLevels(questionTypes) {
const seen = [];
(questionTypes || []).forEach((qt) => {
if ((qt.count || 0) > 0 && !seen.includes(qt.bloomLevel)) seen.push(qt.bloomLevel);
});
return seen;
}

/** The default question type for a Bloom level, used when seeding one. */
export function defaultTypeForLevel(bloomLevel) {
return (
(DEFAULT_BLOOM_TYPE_PREFERENCES[bloomLevel] || [])[0] || QUESTION_TYPES.MULTIPLE_CHOICE
);
}

/**
* Build a breakdown for an objective saved before question types existed.
*
* Such an objective carries Bloom levels and a `questionCount` but no types, so
* under a model where selection is derived it would open with every chip grey
* and a total of zero — its configuration would look wiped. Seeding
* reconstructs an equivalent breakdown from what it does have, in memory only:
* nothing is written until the instructor saves the objective.
*
* Each level gets its default type and an even share of the count. The share is
* floored at one per level, because a level with no questions would immediately
* deselect itself and lose a level the instructor had chosen. That floor is the
* only case where the total changes — measured across 866 real legacy rows, 851
* keep their count exactly and 15 gain a single question, all of them objectives
* with three Bloom levels but only two questions, where today the third level
* silently generates nothing anyway.
*/
export function seedQuestionTypes(bloomLevels, questionCount) {
const levels = (bloomLevels || []).filter(Boolean);
if (levels.length === 0) return [];

const target = Math.min(
MAX_QUESTIONS_PER_OBJECTIVE,
Math.max(levels.length, parseInt(questionCount, 10) || 0)
);
const base = Math.floor(target / levels.length);
const remainder = target % levels.length;

return levels.map((level, index) => ({
bloomLevel: level,
questionType: defaultTypeForLevel(level),
count: base + (index < remainder ? 1 : 0),
}));
}

/**
* Collapse repeated (bloomLevel, questionType) pairs into one entry.
*
* Two entries naming the same pair are not two things to generate, they are one
* thing said twice — the server merges them the same way. Doing it on load
* matters because the +/- handler finds a pair by its first match: against a
* duplicated pair it would edit only one of the two entries while the displayed
* total counts both, so the stepper could never reach the number on screen.
* Objectives stored before the merge existed can still carry duplicates.
*
* A faithful re-description, so it does not clamp: the objective cap is enforced
* where counts are changed and again on save, and silently shrinking a stored
* total here would misreport what the objective is currently set to.
*/
export function mergeQuestionTypes(questionTypes) {
const merged = new Map();
(questionTypes || []).forEach((qt) => {
if (!qt) return;
const key = JSON.stringify([qt.bloomLevel, qt.questionType]);
const existing = merged.get(key);
if (existing) existing.count += qt.count || 0;
else merged.set(key, { bloomLevel: qt.bloomLevel, questionType: qt.questionType, count: qt.count || 0 });
});
return [...merged.values()];
}

/**
* The breakdown to render for a granular objective: its own if it has one,
* otherwise a seeded equivalent. Callers never need to know which they got.
*/
export function questionTypesFor(granular) {
const existing = granular?.questionTypes;
if (Array.isArray(existing) && existing.length > 0) return mergeQuestionTypes(existing);
return seedQuestionTypes(granular?.bloomTaxonomies, granular?.questionCount);
}
9 changes: 6 additions & 3 deletions client/src/pages/QuestionGeneration.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,9 +258,12 @@ export default function QuestionGeneration() {
return;
}
group.items.forEach((item) => {
if (item.mode === "manual" && item.bloom.length === 0) {
hasBloomError = true;
}
// A Bloom level exists on an item only while it has a question type
// with a count, so "has levels" and "has something to generate" are the
// same condition — an item whose types were all zeroed arrives here with
// no levels and is caught by this one check.
if (item.mode !== "manual") return;
if (item.bloom.length === 0) hasBloomError = true;
});
});

Expand Down
112 changes: 0 additions & 112 deletions client/src/pages/Settings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,6 @@ import {
MoodleConnectionPanel,
} from "../components/lms/LmsConnectionPanels";
import { CO_INSTRUCTOR_PERMISSIONS } from "../lib/permissions";
import {
QUESTION_TYPES,
DEFAULT_BLOOM_TYPE_PREFERENCES,
BLOOM_LEVELS,
} from "../lib/constants";
import { BLOOM_BADGE_COLORS } from "../lib/bloom";

const TYPE_LABELS = {
[QUESTION_TYPES.MULTIPLE_CHOICE]: "Multiple Choice",
[QUESTION_TYPES.FILL_IN_THE_BLANK]: "Fill-in-the-blank",
[QUESTION_TYPES.CALCULATION]: "Calculation",
[QUESTION_TYPES.OPEN_ENDED]: "Open-ended",
};

// Pipeline stages a course owner can tune, keyed to the server's
// OPERATION_GROUPS so the labels match what the usage report prints.
Expand Down Expand Up @@ -182,11 +169,6 @@ export default function Settings() {
const [activeTab, setActiveTab] = useState(
canvasReturnState ? "canvas" : openMoodleSettings ? "moodle" : "general"
);
const [bloomPrimary, setBloomPrimary] = useState(() =>
Object.fromEntries(
BLOOM_LEVELS.map((level) => [level, DEFAULT_BLOOM_TYPE_PREFERENCES[level][0]])
)
);
const [prompts, setPrompts] = useState(() => buildPromptState());
// Co-instructor permission toggles (owner only). Default every feature to
// enabled; the stored map only carries explicit restrictions.
Expand Down Expand Up @@ -227,16 +209,6 @@ export default function Settings() {
if (settings.prompts) {
setPrompts(buildPromptState(settings.prompts));
}
if (settings.bloomTypePreferences) {
setBloomPrimary((prev) => {
const next = { ...prev };
for (const level of BLOOM_LEVELS) {
const prefs = settings.bloomTypePreferences[level];
if (prefs && prefs.length > 0) next[level] = prefs[0];
}
return next;
});
}
setReasoningEffort(buildEffortState(settings.reasoningEffort));
setAutoFixEnabled(settings.autoFixEnabled !== false);
if (settings.coInstructorPermissions) {
Expand Down Expand Up @@ -268,19 +240,8 @@ export default function Settings() {
showToast("No course selected. Please select a course first.", "error");
return;
}
// Primary first, then the default fallbacks minus the primary
const bloomTypePreferences = Object.fromEntries(
BLOOM_LEVELS.map((level) => {
const primary = bloomPrimary[level];
const rest = DEFAULT_BLOOM_TYPE_PREFERENCES[level].filter(
(type) => type !== primary
);
return [level, [primary, ...rest]];
})
);
saveMutation.mutate({
prompts,
bloomTypePreferences,
// Only the owner may change co-instructor permissions or the generation
// controls; the server strips them from a non-owner's update regardless.
...(isOwner
Expand All @@ -297,15 +258,6 @@ export default function Settings() {
});
};

const handleResetBloom = () => {
setBloomPrimary(
Object.fromEntries(
BLOOM_LEVELS.map((level) => [level, DEFAULT_BLOOM_TYPE_PREFERENCES[level][0]])
)
);
showToast("Bloom defaults restored — click Save All Changes to apply.", "info");
};

const handleCopyCode = async () => {
if (!enrollmentCode) {
showToast("No code to copy", "warning");
Expand Down Expand Up @@ -414,70 +366,6 @@ export default function Settings() {

{activeTab === "general" && (
<div className="space-y-8">
<section className="rounded-2xl bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-ink">
Question Type by Bloom Level
</h2>
<p className="mt-1 mb-5 text-sm text-muted">
Set the primary question type generated for each Bloom's Taxonomy level.
Changes apply to this course only. The default mapping is used when no
override is set.
</p>

<div className="overflow-x-auto">
<table className="w-full min-w-[480px] text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-muted">
<th className="py-2 pr-4 font-semibold">Bloom's Level</th>
<th className="py-2 pr-4 font-semibold">Primary Question Type</th>
<th className="py-2 font-semibold">Default</th>
</tr>
</thead>
<tbody>
{BLOOM_LEVELS.map((level) => (
<tr key={level} className="border-b border-gray-100">
<td className="py-3 pr-4">
<span
className={`rounded-full px-3 py-1 text-xs font-semibold ${BLOOM_BADGE_COLORS[level]}`}
>
{level}
</span>
</td>
<td className="py-3 pr-4">
<select
aria-label={`Default question type for ${level}`}
value={bloomPrimary[level]}
onChange={(event) =>
setBloomPrimary((prev) => ({
...prev,
[level]: event.target.value,
}))
}
className="w-full max-w-xs rounded-lg border border-gray-300 bg-white px-3 py-2 text-ink focus:border-primary focus:outline-none"
>
{Object.values(QUESTION_TYPES).map((type) => (
<option key={type} value={type}>
{TYPE_LABELS[type]}
</option>
))}
</select>
</td>
<td className="py-3 text-muted">
{TYPE_LABELS[DEFAULT_BLOOM_TYPE_PREFERENCES[level][0]]}
</td>
</tr>
))}
</tbody>
</table>
</div>

<div className="mt-4">
<button type="button" onClick={handleResetBloom} className={secondaryBtnClass}>
<i className="fas fa-undo" /> Reset to Defaults
</button>
</div>
</section>

<section className="rounded-2xl bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-ink">Course invite code</h2>
<p className="mt-1 mb-5 text-sm text-muted">
Expand Down
1 change: 1 addition & 0 deletions client/src/pages/question-generation/AIGenerateModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export default function AIGenerateModal({ course, onClose, onSaved }) {
granularObjectives: objective.granularObjectives.map((go) => ({
text: typeof go === "string" ? go : go.text,
bloomTaxonomies: typeof go === "string" ? [] : go.bloomTaxonomies || [],
questionTypes: typeof go === "string" ? [] : go.questionTypes || [],
})),
});
if (!data.success) {
Expand Down
77 changes: 77 additions & 0 deletions client/src/pages/question-generation/BloomTypePanel.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { QUESTION_TYPES, MAX_QUESTIONS_PER_OBJECTIVE } from "../../lib/constants";
import { pairCount } from "../../lib/questionTypes";
import { formatQuestionTypeLabel } from "../../lib/utils";

const TYPE_ORDER = [
QUESTION_TYPES.MULTIPLE_CHOICE,
QUESTION_TYPES.FILL_IN_THE_BLANK,
QUESTION_TYPES.CALCULATION,
QUESTION_TYPES.OPEN_ENDED,
];

// Per-Bloom-level breakdown of how many questions of each type to generate.
// Rendered below the Bloom chip row when a selected chip is expanded.
//
// Taking every count for this level to zero deselects the level: selection is
// derived from these numbers rather than tracked alongside them.
export default function BloomTypePanel({
bloomLevel,
questionTypes,
objectiveTotal,
onChangeCount,
}) {
// pairCount sums, matching the card's total and the chip badge. Reading the
// first match instead would show a different number for the same data.
const countFor = (type) => pairCount(questionTypes, bloomLevel, type);
const objectiveFull = objectiveTotal >= MAX_QUESTIONS_PER_OBJECTIVE;

return (
<div className="mt-2 rounded-lg border border-gray-200 bg-page p-3">
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">
Question types for {bloomLevel}
</div>
<div className="flex flex-nowrap items-center gap-1.5 overflow-x-auto">
{TYPE_ORDER.map((type) => {
const count = countFor(type);
const label = formatQuestionTypeLabel(type);
return (
<div
key={type}
className="flex shrink-0 items-center gap-1 rounded-full border border-gray-200 bg-white py-0.5 pl-2 pr-1"
>
<span className="text-xs font-medium text-ink">{label}</span>
<div className="flex items-center gap-0.5">
<button
type="button"
aria-label={`Decrease ${label} count for ${bloomLevel}`}
disabled={count <= 0}
onClick={() => onChangeCount(type, -1)}
className="flex h-5 w-5 items-center justify-center rounded-md border border-gray-200 text-muted transition-colors hover:bg-gray-50 disabled:opacity-30"
>
<i className="fas fa-minus text-[9px]" />
</button>
<span className="w-3.5 text-center text-xs font-semibold text-ink">
{count}
</span>
<button
type="button"
aria-label={`Increase ${label} count for ${bloomLevel}`}
title={
objectiveFull
? `This objective is at its limit of ${MAX_QUESTIONS_PER_OBJECTIVE} questions`
: undefined
}
disabled={objectiveFull}
onClick={() => onChangeCount(type, 1)}
className="flex h-5 w-5 items-center justify-center rounded-md border border-gray-200 text-muted transition-colors hover:bg-gray-50 disabled:opacity-30"
>
<i className="fas fa-plus text-[9px]" />
</button>
</div>
</div>
);
})}
</div>
</div>
);
}
Loading
Loading