question-bank-framework 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/core/config.tsx +10 -0
- package/core/index.ts +10 -0
- package/core/types.ts +254 -52
- package/framework/components/markdown-content.tsx +19 -8
- package/framework/components/question-bank.tsx +32 -3
- package/framework/components/question-response.tsx +211 -0
- package/framework/components/quiz-list.tsx +3 -3
- package/framework/components/quiz-page.tsx +126 -38
- package/framework/convex/quizzes.ts +2 -2
- package/framework/convex/schema.ts +1 -1
- package/framework/index.ts +1 -0
- package/framework/lib/question-filter.ts +3 -1
- package/package.json +1 -1
- package/template/content/questions.json +175 -15
package/README.md
CHANGED
|
@@ -99,6 +99,33 @@ defineBank({
|
|
|
99
99
|
});
|
|
100
100
|
```
|
|
101
101
|
|
|
102
|
+
## Question kinds
|
|
103
|
+
|
|
104
|
+
Every question has `id/title/prompt/tags`; `kind` (default `written`) picks the
|
|
105
|
+
response UI and grading. All kinds except `written` are auto-graded in quizzes
|
|
106
|
+
(Check answer → per-question feedback, the bank's answer shown as the explanation).
|
|
107
|
+
|
|
108
|
+
| kind | extra fields | answer UI |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| `written` | — | free-text box; "Show answer" reveals `answers[id]` |
|
|
111
|
+
| `single` | `options`, `correctIds` (exactly one) | radio list |
|
|
112
|
+
| `multiple` | `options`, `correctIds` (≥ 1, exact-set match) | checkbox list |
|
|
113
|
+
| `numerical` | `numeric: { value, tolerance?, unit? }` | number input; correct within ±`tolerance` (default exact). Accepts `1,756`, `1.2e3`. |
|
|
114
|
+
| `matching` | shared `options`, `items: [{ id, text, correctId }]` | one dropdown per item, options lettered A, B, … |
|
|
115
|
+
| `dropdown` | `blanks: [{ id, label?, options, correctId }]` | one dropdown per blank, each with its own options |
|
|
116
|
+
|
|
117
|
+
```json
|
|
118
|
+
{ "id": "q7", "kind": "matching", "title": "…", "prompt": "Match each join to its cost.", "tags": [],
|
|
119
|
+
"options": [{ "id": "a", "text": "Hash join" }, { "id": "b", "text": "Sort-merge join" }],
|
|
120
|
+
"items": [{ "id": "1", "text": "`3(M + N)`", "correctId": "a" }] }
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Matching and dropdown questions earn partial credit per item/blank in the
|
|
124
|
+
review (`gradeAnswer`), but only count as correct in the quiz score when every
|
|
125
|
+
item is right. `defineBank` validates each question's fields against its kind
|
|
126
|
+
and throws on mismatches (e.g. a `correctId` that isn't one of the options).
|
|
127
|
+
Don't repeat the option list or answer table in `prompt`; the UI renders them.
|
|
128
|
+
|
|
102
129
|
## Dev
|
|
103
130
|
|
|
104
131
|
```sh
|
package/core/config.tsx
CHANGED
|
@@ -31,6 +31,10 @@ export type {
|
|
|
31
31
|
BankSiteConfig,
|
|
32
32
|
BaseQuestion,
|
|
33
33
|
DefineBankInput,
|
|
34
|
+
DropdownBlank,
|
|
35
|
+
MatchingItem,
|
|
36
|
+
NumericAnswer,
|
|
37
|
+
PairingSlot,
|
|
34
38
|
QuestionKind,
|
|
35
39
|
QuestionOption,
|
|
36
40
|
QuestionStatus,
|
|
@@ -40,13 +44,19 @@ export type {
|
|
|
40
44
|
} from './types.ts';
|
|
41
45
|
export {
|
|
42
46
|
getCorrectIds,
|
|
47
|
+
getPairingSlots,
|
|
48
|
+
gradeAnswer,
|
|
43
49
|
isAnswerCorrect,
|
|
44
50
|
isAnswerProvided,
|
|
45
51
|
isChoiceQuestion,
|
|
52
|
+
isGradeableQuestion,
|
|
53
|
+
isPairingQuestion,
|
|
54
|
+
parseNumericAnswer,
|
|
46
55
|
questionKind,
|
|
47
56
|
questionKindLabels,
|
|
48
57
|
questionKinds,
|
|
49
58
|
scoreAnswers,
|
|
59
|
+
toPairings,
|
|
50
60
|
toSelectedIds,
|
|
51
61
|
} from './types.ts';
|
|
52
62
|
export { questionStatuses } from './types.ts';
|
package/core/index.ts
CHANGED
|
@@ -7,14 +7,20 @@ export {
|
|
|
7
7
|
useBank,
|
|
8
8
|
defineBank,
|
|
9
9
|
getCorrectIds,
|
|
10
|
+
getPairingSlots,
|
|
11
|
+
gradeAnswer,
|
|
10
12
|
isAnswerCorrect,
|
|
11
13
|
isAnswerProvided,
|
|
12
14
|
isChoiceQuestion,
|
|
15
|
+
isGradeableQuestion,
|
|
16
|
+
isPairingQuestion,
|
|
17
|
+
parseNumericAnswer,
|
|
13
18
|
questionKind,
|
|
14
19
|
questionKindLabels,
|
|
15
20
|
questionKinds,
|
|
16
21
|
questionStatuses,
|
|
17
22
|
scoreAnswers,
|
|
23
|
+
toPairings,
|
|
18
24
|
toSelectedIds,
|
|
19
25
|
} from './config.tsx';
|
|
20
26
|
export type {
|
|
@@ -26,6 +32,10 @@ export type {
|
|
|
26
32
|
BankSiteConfig,
|
|
27
33
|
BaseQuestion,
|
|
28
34
|
DefineBankInput,
|
|
35
|
+
DropdownBlank,
|
|
36
|
+
MatchingItem,
|
|
37
|
+
NumericAnswer,
|
|
38
|
+
PairingSlot,
|
|
29
39
|
QuestionKind,
|
|
30
40
|
QuestionOption,
|
|
31
41
|
QuestionStatus,
|
package/core/types.ts
CHANGED
|
@@ -9,17 +9,25 @@ export type QuestionStatus = (typeof questionStatuses)[number];
|
|
|
9
9
|
export type QuestionStatusMap = Record<string, QuestionStatus>;
|
|
10
10
|
|
|
11
11
|
// ---------------------------------------------------------------------------
|
|
12
|
-
// Question kinds
|
|
12
|
+
// Question kinds. `written` is free response; every other kind is
|
|
13
|
+
// auto-gradeable:
|
|
14
|
+
// single / multiple — pick option(s) from `options`
|
|
15
|
+
// numerical — type a number, checked against `numeric`
|
|
16
|
+
// matching — pair each of `items` with one of the shared `options`
|
|
17
|
+
// dropdown — fill each of `blanks` from its own option list
|
|
13
18
|
// ---------------------------------------------------------------------------
|
|
14
19
|
|
|
15
|
-
export type QuestionKind = 'written' | 'single' | 'multiple';
|
|
20
|
+
export type QuestionKind = 'written' | 'single' | 'multiple' | 'numerical' | 'matching' | 'dropdown';
|
|
16
21
|
|
|
17
|
-
export const questionKinds: readonly QuestionKind[] = ['written', 'single', 'multiple'];
|
|
22
|
+
export const questionKinds: readonly QuestionKind[] = ['written', 'single', 'multiple', 'numerical', 'matching', 'dropdown'];
|
|
18
23
|
|
|
19
24
|
export const questionKindLabels: Record<QuestionKind, string> = {
|
|
20
25
|
written: 'Written',
|
|
21
26
|
single: 'Single choice',
|
|
22
27
|
multiple: 'Multiple choice',
|
|
28
|
+
numerical: 'Numerical',
|
|
29
|
+
matching: 'Matching',
|
|
30
|
+
dropdown: 'Dropdowns',
|
|
23
31
|
};
|
|
24
32
|
|
|
25
33
|
export interface QuestionOption {
|
|
@@ -29,20 +37,61 @@ export interface QuestionOption {
|
|
|
29
37
|
text: string;
|
|
30
38
|
}
|
|
31
39
|
|
|
32
|
-
/**
|
|
33
|
-
export
|
|
40
|
+
/** A left-hand row of a matching question, answered from the question's shared `options`. */
|
|
41
|
+
export interface MatchingItem {
|
|
42
|
+
/** Stable within the question, e.g. "1", "2". */
|
|
43
|
+
id: string;
|
|
44
|
+
/** Row text. Markdown supported. */
|
|
45
|
+
text: string;
|
|
46
|
+
/** Id of the correct option in the question's `options`. */
|
|
47
|
+
correctId: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One blank of a dropdown question, with its own option list. */
|
|
51
|
+
export interface DropdownBlank {
|
|
52
|
+
/** Stable within the question, e.g. "1", "key". */
|
|
53
|
+
id: string;
|
|
54
|
+
/** Text shown before the dropdown, e.g. "A possible candidate key is:". Markdown supported. */
|
|
55
|
+
label?: string;
|
|
56
|
+
options: QuestionOption[];
|
|
57
|
+
/** Id of the correct option in this blank's `options`. */
|
|
58
|
+
correctId: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Expected answer of a numerical question. */
|
|
62
|
+
export interface NumericAnswer {
|
|
63
|
+
value: number;
|
|
64
|
+
/** Absolute tolerance: answers within ±tolerance of value are correct. Defaults to 0 (exact). */
|
|
65
|
+
tolerance?: number;
|
|
66
|
+
/** Unit shown next to the input, e.g. "I/Os" or "bytes". */
|
|
67
|
+
unit?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Stored quiz answer:
|
|
72
|
+
* written / numerical — the typed text
|
|
73
|
+
* single / multiple — selected option ids
|
|
74
|
+
* matching / dropdown — item/blank id → chosen option id
|
|
75
|
+
*/
|
|
76
|
+
export type QuizAnswer = string | string[] | Record<string, string>;
|
|
34
77
|
|
|
35
78
|
export interface BaseQuestion {
|
|
36
79
|
id: string;
|
|
37
80
|
title: string;
|
|
38
81
|
prompt: string;
|
|
39
82
|
tags: string[];
|
|
40
|
-
/** Defaults to 'written' when omitted.
|
|
83
|
+
/** Defaults to 'written' when omitted. Each other kind requires its own answer fields (see below). */
|
|
41
84
|
kind?: QuestionKind;
|
|
42
|
-
/** Choice options
|
|
85
|
+
/** Choice options (single/multiple), or the shared option list of a matching question. */
|
|
43
86
|
options?: QuestionOption[];
|
|
44
|
-
/** Correct option ids. Single choice requires exactly one. */
|
|
87
|
+
/** Correct option ids (single/multiple). Single choice requires exactly one. */
|
|
45
88
|
correctIds?: string[];
|
|
89
|
+
/** Rows to match (matching). */
|
|
90
|
+
items?: MatchingItem[];
|
|
91
|
+
/** Blanks to fill (dropdown). */
|
|
92
|
+
blanks?: DropdownBlank[];
|
|
93
|
+
/** Expected number (numerical). */
|
|
94
|
+
numeric?: NumericAnswer;
|
|
46
95
|
// Subjects extend this interface with their own fields; see template/bank.config.ts.
|
|
47
96
|
}
|
|
48
97
|
|
|
@@ -161,48 +210,110 @@ export function defineBank<Q extends BaseQuestion>(input: DefineBankInput<Q>): B
|
|
|
161
210
|
};
|
|
162
211
|
}
|
|
163
212
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
if (options.length < 2) {
|
|
180
|
-
throw new Error(`Question "${question.id}" (${kind}) needs at least 2 options.`);
|
|
213
|
+
type KindField = 'options' | 'correctIds' | 'items' | 'blanks' | 'numeric';
|
|
214
|
+
|
|
215
|
+
const kindFields: Record<QuestionKind, readonly KindField[]> = {
|
|
216
|
+
written: [],
|
|
217
|
+
single: ['options', 'correctIds'],
|
|
218
|
+
multiple: ['options', 'correctIds'],
|
|
219
|
+
numerical: ['numeric'],
|
|
220
|
+
matching: ['options', 'items'],
|
|
221
|
+
dropdown: ['blanks'],
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
function validateOptionList(questionId: string, where: string, options: readonly QuestionOption[] | undefined): Set<string> {
|
|
225
|
+
const list = options ?? [];
|
|
226
|
+
if (list.length < 2) {
|
|
227
|
+
throw new Error(`Question "${questionId}" ${where} needs at least 2 options.`);
|
|
181
228
|
}
|
|
182
229
|
const seen = new Set<string>();
|
|
183
|
-
for (const option of
|
|
230
|
+
for (const option of list) {
|
|
184
231
|
if (!option.id || seen.has(option.id)) {
|
|
185
|
-
throw new Error(`Question "${
|
|
232
|
+
throw new Error(`Question "${questionId}" ${where} has a duplicate or empty option id "${option.id}".`);
|
|
186
233
|
}
|
|
187
234
|
seen.add(option.id);
|
|
188
235
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
236
|
+
return seen;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function validateQuestion(question: BaseQuestion): void {
|
|
240
|
+
const kind: QuestionKind = question.kind ?? 'written';
|
|
241
|
+
if (!questionKinds.includes(kind)) {
|
|
242
|
+
throw new Error(`Question "${question.id}" has unknown kind "${question.kind}".`);
|
|
192
243
|
}
|
|
193
|
-
|
|
194
|
-
|
|
244
|
+
const allowed = kindFields[kind];
|
|
245
|
+
for (const field of ['options', 'correctIds', 'items', 'blanks', 'numeric'] as const) {
|
|
246
|
+
if (question[field] !== undefined && !allowed.includes(field)) {
|
|
247
|
+
throw new Error(`Question "${question.id}" is ${kind} and must not define ${field}.`);
|
|
248
|
+
}
|
|
195
249
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
250
|
+
|
|
251
|
+
switch (kind) {
|
|
252
|
+
case 'written':
|
|
253
|
+
return;
|
|
254
|
+
case 'single':
|
|
255
|
+
case 'multiple': {
|
|
256
|
+
const seen = validateOptionList(question.id, `(${kind})`, question.options);
|
|
257
|
+
const correctIds = question.correctIds ?? [];
|
|
258
|
+
if (kind === 'single' && correctIds.length !== 1) {
|
|
259
|
+
throw new Error(`Question "${question.id}" (single) needs exactly one correctId.`);
|
|
260
|
+
}
|
|
261
|
+
if (kind === 'multiple' && correctIds.length < 1) {
|
|
262
|
+
throw new Error(`Question "${question.id}" (multiple) needs at least one correctId.`);
|
|
263
|
+
}
|
|
264
|
+
for (const id of correctIds) {
|
|
265
|
+
if (!seen.has(id)) throw new Error(`Question "${question.id}" has unknown correctId "${id}".`);
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
case 'numerical': {
|
|
270
|
+
const numeric = question.numeric;
|
|
271
|
+
if (!numeric || typeof numeric.value !== 'number' || !Number.isFinite(numeric.value)) {
|
|
272
|
+
throw new Error(`Question "${question.id}" (numerical) needs numeric.value as a finite number.`);
|
|
273
|
+
}
|
|
274
|
+
if (numeric.tolerance !== undefined && !(Number.isFinite(numeric.tolerance) && numeric.tolerance >= 0)) {
|
|
275
|
+
throw new Error(`Question "${question.id}" (numerical) has an invalid tolerance.`);
|
|
276
|
+
}
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
case 'matching': {
|
|
280
|
+
const seen = validateOptionList(question.id, '(matching)', question.options);
|
|
281
|
+
const items = question.items ?? [];
|
|
282
|
+
if (items.length < 1) throw new Error(`Question "${question.id}" (matching) needs at least 1 item.`);
|
|
283
|
+
const itemIds = new Set<string>();
|
|
284
|
+
for (const item of items) {
|
|
285
|
+
if (!item.id || itemIds.has(item.id)) {
|
|
286
|
+
throw new Error(`Question "${question.id}" has a duplicate or empty item id "${item.id}".`);
|
|
287
|
+
}
|
|
288
|
+
itemIds.add(item.id);
|
|
289
|
+
if (!seen.has(item.correctId)) {
|
|
290
|
+
throw new Error(`Question "${question.id}" item "${item.id}" has unknown correctId "${item.correctId}".`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
case 'dropdown': {
|
|
296
|
+
const blanks = question.blanks ?? [];
|
|
297
|
+
if (blanks.length < 1) throw new Error(`Question "${question.id}" (dropdown) needs at least 1 blank.`);
|
|
298
|
+
const blankIds = new Set<string>();
|
|
299
|
+
for (const blank of blanks) {
|
|
300
|
+
if (!blank.id || blankIds.has(blank.id)) {
|
|
301
|
+
throw new Error(`Question "${question.id}" has a duplicate or empty blank id "${blank.id}".`);
|
|
302
|
+
}
|
|
303
|
+
blankIds.add(blank.id);
|
|
304
|
+
const seen = validateOptionList(question.id, `blank "${blank.id}"`, blank.options);
|
|
305
|
+
if (!seen.has(blank.correctId)) {
|
|
306
|
+
throw new Error(`Question "${question.id}" blank "${blank.id}" has unknown correctId "${blank.correctId}".`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return;
|
|
199
310
|
}
|
|
200
311
|
}
|
|
201
312
|
}
|
|
202
313
|
|
|
203
314
|
// ---------------------------------------------------------------------------
|
|
204
|
-
//
|
|
205
|
-
// (ids) stays decoupled from presentation (letters, markdown).
|
|
315
|
+
// Answer helpers. All UI + scoring goes through these so answer storage
|
|
316
|
+
// (ids, raw text) stays decoupled from presentation (letters, markdown).
|
|
206
317
|
// ---------------------------------------------------------------------------
|
|
207
318
|
|
|
208
319
|
/** Effective kind: questions without `kind` are written. */
|
|
@@ -210,19 +321,60 @@ export function questionKind(question: BaseQuestion): QuestionKind {
|
|
|
210
321
|
return question.kind ?? 'written';
|
|
211
322
|
}
|
|
212
323
|
|
|
324
|
+
/** Single or multiple choice (pick from `options`). */
|
|
213
325
|
export function isChoiceQuestion(question: BaseQuestion): boolean {
|
|
214
326
|
const kind = questionKind(question);
|
|
215
327
|
return kind === 'single' || kind === 'multiple';
|
|
216
328
|
}
|
|
217
329
|
|
|
218
|
-
/**
|
|
330
|
+
/** Matching or dropdown (each row/blank maps to one option id). */
|
|
331
|
+
export function isPairingQuestion(question: BaseQuestion): boolean {
|
|
332
|
+
const kind = questionKind(question);
|
|
333
|
+
return kind === 'matching' || kind === 'dropdown';
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Every kind except written can be auto-graded. */
|
|
337
|
+
export function isGradeableQuestion(question: BaseQuestion): boolean {
|
|
338
|
+
return questionKind(question) !== 'written';
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Correct option ids. Empty unless single/multiple. */
|
|
219
342
|
export function getCorrectIds(question: BaseQuestion): string[] {
|
|
220
343
|
return isChoiceQuestion(question) ? [...(question.correctIds ?? [])] : [];
|
|
221
344
|
}
|
|
222
345
|
|
|
346
|
+
export interface PairingSlot {
|
|
347
|
+
id: string;
|
|
348
|
+
/** Row text (matching) or blank label (dropdown). */
|
|
349
|
+
label: string | undefined;
|
|
350
|
+
options: readonly QuestionOption[];
|
|
351
|
+
correctId: string;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Rows of a matching question or blanks of a dropdown question, uniformly. Empty otherwise. */
|
|
355
|
+
export function getPairingSlots(question: BaseQuestion): PairingSlot[] {
|
|
356
|
+
const kind = questionKind(question);
|
|
357
|
+
if (kind === 'matching') {
|
|
358
|
+
const options = question.options ?? [];
|
|
359
|
+
return (question.items ?? []).map((item) => ({ id: item.id, label: item.text, options, correctId: item.correctId }));
|
|
360
|
+
}
|
|
361
|
+
if (kind === 'dropdown') {
|
|
362
|
+
return (question.blanks ?? []).map((blank) => ({
|
|
363
|
+
id: blank.id,
|
|
364
|
+
label: blank.label,
|
|
365
|
+
options: blank.options,
|
|
366
|
+
correctId: blank.correctId,
|
|
367
|
+
}));
|
|
368
|
+
}
|
|
369
|
+
return [];
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const isRecordAnswer = (answer: QuizAnswer | undefined): answer is Record<string, string> =>
|
|
373
|
+
typeof answer === 'object' && answer !== null && !Array.isArray(answer);
|
|
374
|
+
|
|
223
375
|
/** Normalize a stored answer to selected option ids — tolerates legacy strings. */
|
|
224
376
|
export function toSelectedIds(question: BaseQuestion, answer: QuizAnswer | undefined): string[] {
|
|
225
|
-
if (answer === undefined) return [];
|
|
377
|
+
if (answer === undefined || isRecordAnswer(answer)) return [];
|
|
226
378
|
const ids =
|
|
227
379
|
typeof answer === 'string' ? (answer === '' ? [] : [answer]) : answer.filter((id) => typeof id === 'string');
|
|
228
380
|
if (!isChoiceQuestion(question)) return ids;
|
|
@@ -230,9 +382,34 @@ export function toSelectedIds(question: BaseQuestion, answer: QuizAnswer | undef
|
|
|
230
382
|
return ids.filter((id) => valid.has(id));
|
|
231
383
|
}
|
|
232
384
|
|
|
233
|
-
/**
|
|
385
|
+
/** Normalize a stored answer to slot id → option id, dropping unknown slots/options. */
|
|
386
|
+
export function toPairings(question: BaseQuestion, answer: QuizAnswer | undefined): Record<string, string> {
|
|
387
|
+
if (!isRecordAnswer(answer)) return {};
|
|
388
|
+
const out: Record<string, string> = {};
|
|
389
|
+
for (const slot of getPairingSlots(question)) {
|
|
390
|
+
const chosen = answer[slot.id];
|
|
391
|
+
if (typeof chosen === 'string' && slot.options.some((o) => o.id === chosen)) out[slot.id] = chosen;
|
|
392
|
+
}
|
|
393
|
+
return out;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Parse a typed number. Accepts thousands separators ("1,756"), spaces,
|
|
398
|
+
* a leading "+", scientific notation and a trailing "%" (kept as the
|
|
399
|
+
* literal number). Returns null when the text is not a number.
|
|
400
|
+
*/
|
|
401
|
+
export function parseNumericAnswer(text: string): number | null {
|
|
402
|
+
const cleaned = text.trim().replace(/[\s,_]/g, '').replace(/%$/, '').replace(/^\+/, '');
|
|
403
|
+
if (cleaned === '' || !/^-?(\d+\.?\d*|\.\d+)(e[-+]?\d+)?$/i.test(cleaned)) return null;
|
|
404
|
+
const value = Number(cleaned);
|
|
405
|
+
return Number.isFinite(value) ? value : null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Whether the user has provided an answer (non-blank text, ≥1 selection, or ≥1 pairing). */
|
|
234
409
|
export function isAnswerProvided(question: BaseQuestion, answer: QuizAnswer | undefined): boolean {
|
|
235
410
|
if (answer === undefined) return false;
|
|
411
|
+
if (isPairingQuestion(question)) return Object.keys(toPairings(question, answer)).length > 0;
|
|
412
|
+
if (isRecordAnswer(answer)) return false;
|
|
236
413
|
if (typeof answer === 'string') return answer.trim().length > 0;
|
|
237
414
|
if (!isChoiceQuestion(question)) return answer.some((id) => id.trim().length > 0);
|
|
238
415
|
const valid = new Set((question.options ?? []).map((o) => o.id));
|
|
@@ -240,20 +417,45 @@ export function isAnswerProvided(question: BaseQuestion, answer: QuizAnswer | un
|
|
|
240
417
|
}
|
|
241
418
|
|
|
242
419
|
/**
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
420
|
+
* Per-part credit for an answer: { earned, possible }. null for written
|
|
421
|
+
* questions or when unanswered. Matching/dropdown earn one part per correct
|
|
422
|
+
* row/blank; every other gradeable kind is a single all-or-nothing part.
|
|
246
423
|
*/
|
|
247
|
-
export function
|
|
248
|
-
|
|
249
|
-
|
|
424
|
+
export function gradeAnswer(
|
|
425
|
+
question: BaseQuestion,
|
|
426
|
+
answer: QuizAnswer | undefined,
|
|
427
|
+
): { earned: number; possible: number } | null {
|
|
428
|
+
if (!isGradeableQuestion(question) || !isAnswerProvided(question, answer)) return null;
|
|
429
|
+
const kind = questionKind(question);
|
|
430
|
+
if (kind === 'numerical') {
|
|
431
|
+
const value = typeof answer === 'string' ? parseNumericAnswer(answer) : null;
|
|
432
|
+
const expected = question.numeric;
|
|
433
|
+
if (value === null || !expected) return { earned: 0, possible: 1 };
|
|
434
|
+
const tolerance = expected.tolerance ?? 0;
|
|
435
|
+
// Small relative epsilon so 0.1 + 0.2 style float noise never fails an exact answer.
|
|
436
|
+
const epsilon = Math.max(Math.abs(expected.value), 1) * 1e-9;
|
|
437
|
+
return { earned: Math.abs(value - expected.value) <= tolerance + epsilon ? 1 : 0, possible: 1 };
|
|
438
|
+
}
|
|
439
|
+
if (kind === 'matching' || kind === 'dropdown') {
|
|
440
|
+
const pairings = toPairings(question, answer);
|
|
441
|
+
const slots = getPairingSlots(question);
|
|
442
|
+
const earned = slots.filter((slot) => pairings[slot.id] === slot.correctId).length;
|
|
443
|
+
return { earned, possible: slots.length };
|
|
444
|
+
}
|
|
250
445
|
const selected = new Set(toSelectedIds(question, answer));
|
|
251
446
|
const correct = new Set(getCorrectIds(question));
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
447
|
+
const exact = selected.size === correct.size && [...selected].every((id) => correct.has(id));
|
|
448
|
+
return { earned: exact ? 1 : 0, possible: 1 };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Grade an answer. Returns null for written questions or when unanswered.
|
|
453
|
+
* Single: exactly the correct id. Multiple: exact set match. Numerical:
|
|
454
|
+
* within tolerance. Matching/dropdown: every row/blank correct.
|
|
455
|
+
*/
|
|
456
|
+
export function isAnswerCorrect(question: BaseQuestion, answer: QuizAnswer | undefined): boolean | null {
|
|
457
|
+
const grade = gradeAnswer(question, answer);
|
|
458
|
+
return grade === null ? null : grade.earned === grade.possible;
|
|
257
459
|
}
|
|
258
460
|
|
|
259
461
|
/** Score a set of questions against answers: { correct, gradeable, answered }. */
|
|
@@ -266,7 +468,7 @@ export function scoreAnswers(
|
|
|
266
468
|
let answered = 0;
|
|
267
469
|
for (const question of questions) {
|
|
268
470
|
if (isAnswerProvided(question, answers[question.id])) answered += 1;
|
|
269
|
-
if (!
|
|
471
|
+
if (!isGradeableQuestion(question)) continue;
|
|
270
472
|
gradeable += 1;
|
|
271
473
|
if (isAnswerCorrect(question, answers[question.id]) === true) correct += 1;
|
|
272
474
|
}
|
|
@@ -9,7 +9,7 @@ const components: Components = {
|
|
|
9
9
|
blockquote: ({ ...props }) => <blockquote className="my-4 border-l-2 border-gray-300 pl-4 text-gray-700" {...props} />,
|
|
10
10
|
code: ({ className, children, ...props }) => {
|
|
11
11
|
const isBlock = className?.startsWith('language-') ?? false
|
|
12
|
-
return <code className={cn(isBlock ? 'font-mono text-sm' : 'rounded bg-gray-100 px-1 py-0.5 font-mono text-[0.9em] text-gray-900', className)} {...props}>{children}</code>
|
|
12
|
+
return <code className={cn(isBlock ? 'font-mono text-sm' : 'rounded bg-gray-100 px-1 py-0.5 font-mono text-[0.9em] text-gray-900 break-words', className)} {...props}>{children}</code>
|
|
13
13
|
},
|
|
14
14
|
h1: ({ ...props }) => <h1 className="mb-3 mt-6 text-2xl font-bold tracking-tight text-gray-950 first:mt-0" {...props} />,
|
|
15
15
|
h2: ({ ...props }) => <h2 className="mb-3 mt-6 text-xl font-bold tracking-tight text-gray-950 first:mt-0" {...props} />,
|
|
@@ -98,12 +98,23 @@ const normalizeGfmTables = (content: string) => {
|
|
|
98
98
|
}).join('')
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
101
|
+
// Inline mode: paragraphs render as spans so short markdown (option text,
|
|
102
|
+
// blank labels) can sit inside buttons, select triggers and flex rows.
|
|
103
|
+
const inlineComponents: Components = {
|
|
104
|
+
...components,
|
|
105
|
+
p: ({ ...props }) => <span className="whitespace-pre-wrap" {...props} />,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// min-w-0 + break-words keep long tokens inside the column; display math
|
|
109
|
+
// and wide tables/code scroll horizontally instead of widening the page.
|
|
110
|
+
const blockClassName = 'min-w-0 max-w-full break-words [&_.katex-display]:overflow-x-auto [&_.katex-display]:overflow-y-hidden [&_.katex-display]:py-1'
|
|
111
|
+
|
|
112
|
+
export function MarkdownContent({ content, className, inline = false }: { content: string; className?: string; inline?: boolean }) {
|
|
113
|
+
const markdown = (
|
|
114
|
+
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]} components={inline ? inlineComponents : components}>
|
|
115
|
+
{normalizeGfmTables(normalizeMathDelimiters(content))}
|
|
116
|
+
</ReactMarkdown>
|
|
108
117
|
)
|
|
118
|
+
if (inline) return <span className={cn('min-w-0', className)}>{markdown}</span>
|
|
119
|
+
return <div className={cn(blockClassName, 'text-base leading-7 text-gray-800', className)}>{markdown}</div>
|
|
109
120
|
}
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
import { Search } from 'lucide-react';
|
|
19
19
|
import { useBank } from '../../core/config.tsx';
|
|
20
20
|
import type { BaseQuestion, QuestionKind, QuestionStatus, QuestionStatusMap, QuizSession } from '../../core/types.ts';
|
|
21
|
-
import { isChoiceQuestion, questionKind, questionKindLabels } from '../../core/types.ts';
|
|
21
|
+
import { getPairingSlots, isChoiceQuestion, questionKind, questionKindLabels } from '../../core/types.ts';
|
|
22
22
|
import { Badge } from './ui/badge';
|
|
23
23
|
import { Button } from './ui/button';
|
|
24
24
|
import { Checkbox } from './ui/checkbox';
|
|
@@ -64,10 +64,30 @@ const questionKindStyles: Record<QuestionKind, string> = {
|
|
|
64
64
|
written: 'border-gray-300 bg-gray-50 text-gray-700',
|
|
65
65
|
single: 'border-sky-300 bg-sky-50 text-sky-900',
|
|
66
66
|
multiple: 'border-violet-300 bg-violet-50 text-violet-900',
|
|
67
|
+
numerical: 'border-emerald-300 bg-emerald-50 text-emerald-900',
|
|
68
|
+
matching: 'border-orange-300 bg-orange-50 text-orange-900',
|
|
69
|
+
dropdown: 'border-rose-300 bg-rose-50 text-rose-900',
|
|
67
70
|
};
|
|
68
71
|
|
|
69
72
|
const optionLetter = (index: number): string => String.fromCharCode(65 + index);
|
|
70
73
|
|
|
74
|
+
/** Short structural summary for the Type badge tooltip, e.g. "5 items · 11 options". */
|
|
75
|
+
function kindDetail(question: BaseQuestion): string | undefined {
|
|
76
|
+
switch (questionKind(question)) {
|
|
77
|
+
case 'single':
|
|
78
|
+
case 'multiple':
|
|
79
|
+
return `${question.options?.length ?? 0} options`;
|
|
80
|
+
case 'matching':
|
|
81
|
+
return `${question.items?.length ?? 0} items · ${question.options?.length ?? 0} options`;
|
|
82
|
+
case 'dropdown':
|
|
83
|
+
return `${question.blanks?.length ?? 0} blanks`;
|
|
84
|
+
case 'numerical':
|
|
85
|
+
return question.numeric?.unit ? `Numeric answer (${question.numeric.unit})` : 'Numeric answer';
|
|
86
|
+
default:
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
71
91
|
function QuestionPreviewPopover({ question }: { question: BaseQuestion }) {
|
|
72
92
|
const [open, setOpen] = useState(false);
|
|
73
93
|
const openTimeout = useRef<number | null>(null);
|
|
@@ -184,6 +204,16 @@ function QuestionPreviewPopover({ question }: { question: BaseQuestion }) {
|
|
|
184
204
|
))}
|
|
185
205
|
</ol>
|
|
186
206
|
)}
|
|
207
|
+
{getPairingSlots(question).length > 0 && (
|
|
208
|
+
<ol className="mt-3 list-decimal space-y-1 pl-6 text-sm text-gray-800">
|
|
209
|
+
{getPairingSlots(question).map((slot, index) => (
|
|
210
|
+
<li key={slot.id}>
|
|
211
|
+
<MarkdownContent inline content={slot.label?.trim() ? slot.label : `Blank ${index + 1}`} />
|
|
212
|
+
<span className="text-gray-500"> → ___</span>
|
|
213
|
+
</li>
|
|
214
|
+
))}
|
|
215
|
+
</ol>
|
|
216
|
+
)}
|
|
187
217
|
</div>
|
|
188
218
|
</PopoverContent>
|
|
189
219
|
</Popover>
|
|
@@ -394,9 +424,8 @@ export function QuestionBank() {
|
|
|
394
424
|
header: 'Type',
|
|
395
425
|
cell: ({ row }) => {
|
|
396
426
|
const kind = questionKind(row.original);
|
|
397
|
-
const count = row.original.options?.length ?? 0;
|
|
398
427
|
return (
|
|
399
|
-
<Badge variant="outline" className={questionKindStyles[kind]} title={
|
|
428
|
+
<Badge variant="outline" className={questionKindStyles[kind]} title={kindDetail(row.original)}>
|
|
400
429
|
{questionKindLabels[kind]}
|
|
401
430
|
</Badge>
|
|
402
431
|
);
|