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
5 changes: 5 additions & 0 deletions examples/questions/free-fall.question.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,10 @@
],
"marks": 10, "difficulty": 3,
"subject": "physics", "classes": ["9", "10"], "tags": ["motion", "free fall"],
"provenance": [
{ "kind": "board_exam", "code": "DHA", "name": { "bn": "ঢাকা বোর্ড", "en": "Dhaka Board" }, "year": 2023 },
{ "kind": "board_exam", "code": "RAJ", "name": { "bn": "রাজশাহী বোর্ড", "en": "Rajshahi Board" }, "year": 2019 },
{ "kind": "institution_exam", "code": "VNSC", "name": "ভিকারুননিসা নূন স্কুল এন্ড কলেজ, ঢাকা", "year": null }
],
"license": "CC-BY-4.0", "authors": ["PathshalaX"]
}
27 changes: 27 additions & 0 deletions packages/model/src/question-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,33 @@ describe('question manifests (spec 1.7)', () => {
expect(questionStem(base({ stem: { en: 'Hello' } }), 'bn')).toBe('Hello');
});

describe('provenance (§2.1)', () => {
const board = { kind: 'board_exam', code: 'DHA', name: 'ঢাকা বোর্ড', year: 2023 };
const school = { kind: 'institution_exam', code: 'VNSC', name: { bn: 'ভিকারুননিসা নূন স্কুল এন্ড কলেজ, ঢাকা', en: 'Viqarunnisa Noon School & College, Dhaka' }, year: null };
it('accepts board and school papers, a year or null, a name in one language or many', () => {
const m = base({ provenance: [board, school] });
expect(m.provenance).toEqual([board, school]);
expect(checkQuestion(m)).toEqual([]);
expect(questionManifestSchema.safeParse({ ...m, provenance: [{ kind: 'board_exam', code: 'RAJ', name: 'রাজশাহী বোর্ড' }] }).success).toBe(true); // no year: not known
});
it('accepts a kind it does not know (readers show its code)', () => {
expect(questionManifestSchema.safeParse({ ...base(), provenance: [{ ...board, kind: 'olympiad' }] }).success).toBe(true);
});
it('refuses a code that is not 2 to 8 capitals and digits, a missing name, a year that is not a whole number', () => {
for (const bad of [{ code: 'D' }, { code: 'dha' }, { code: 'TOOLONGXX' }, { code: 'ঢা' }, { code: 'DH-A' }, { name: undefined }, { year: 2023.5 }, { year: '2023' }, { kind: 'Board' }]) {
expect(questionManifestSchema.safeParse({ ...base(), provenance: [{ ...board, ...bad }] }).success, JSON.stringify(bad)).toBe(false);
}
});
it('keeps fields of an entry it does not know', () => {
const m = base({ provenance: [{ ...board, paper: '1st' }] });
expect((m.provenance![0] as Record<string, unknown>).paper).toBe('1st');
});
it('says when the same paper is listed twice', () => {
expect(checkQuestion(base({ provenance: [board, board] }))).toEqual(['provenance board_exam DHA 2023: the same paper twice']);
expect(checkQuestion(base({ provenance: [board, { ...board, year: 2022 }] }))).toEqual([]);
});
});

describe('checkQuestion', () => {
it('a multiple-choice question has two or more options and at most one marked correct', () => {
expect(checkQuestion(mcq([1]))).toEqual([]);
Expand Down
28 changes: 27 additions & 1 deletion packages/model/src/question-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ export const questionPartSchema = z.looseObject({
answer: questionAnswerSchema.optional(),
});

/** Where the question was printed, for people (and so that the same question is not published twice). */
/**
* Where the question was printed, for people (and so that the same question is not published twice). A publisher that must not say which
* book it took a question from leaves it out (spec 1.7 §2.1).
*/
export const questionSourceSchema = z.looseObject({
book: z.string().max(300).optional(),
edition: z.string().max(100).optional(),
Expand All @@ -51,6 +54,25 @@ export const questionSourceSchema = z.looseObject({
page: z.number().int().positive().optional(),
});

/** The kinds of paper this version names in `provenance`. Others may follow: readers show the code of a kind they do not know. */
export const PROVENANCE_KINDS = ['board_exam', 'institution_exam'] as const;

/**
* One exam paper the question was set in (spec 1.7 §2.1): a board's exam (`board_exam`) or a school's or college's test paper
* (`institution_exam`), with a short code to show as a tag and the full name to show on hover. A paper, not the book the question was
* copied from: that is `source`, which a publisher may leave out.
*/
export const questionProvenanceSchema = z.looseObject({
/** `board_exam`, `institution_exam`, or a kind a later version adds. */
kind: z.string().regex(/^[a-z][a-z0-9_]{0,39}$/),
/** The tag: 2 to 8 capital letters and digits, such as `DHA` or `VNSC`. */
code: z.string().regex(/^[A-Z0-9]{2,8}$/),
/** The full name, for people: a string or one per language. */
name: localizedTextSchema,
/** The year of the paper, or null when it is not known. */
year: z.number().int().min(1900).max(2200).nullable().optional(),
});

export const questionManifestSchema = z.looseObject({
format: z.literal('chalk.question'),
manifestVersion: z.literal(1),
Expand All @@ -74,6 +96,8 @@ export const questionManifestSchema = z.looseObject({
classes: z.array(z.string()).optional(),
tags: z.array(z.string()).optional(),
source: questionSourceSchema.optional(),
/** The exam papers the question was set in, newest first (spec 1.7 §2.1). */
provenance: z.array(questionProvenanceSchema).max(100).optional(),
/** The content keys of the pictures the text uses. */
assets: z.array(contentKeySchema).optional(),
license: z.string().optional(),
Expand All @@ -84,6 +108,7 @@ export type QuestionManifest = z.infer<typeof questionManifestSchema>;
export type QuestionAnswer = z.infer<typeof questionAnswerSchema>;
export type QuestionOption = z.infer<typeof questionOptionSchema>;
export type QuestionPart = z.infer<typeof questionPartSchema>;
export type QuestionProvenance = z.infer<typeof questionProvenanceSchema>;

const KEY_IN_TEXT = /c1:[a-z2-7]{25}[aeimquy4]|sha256:[0-9a-f]{64}/g;

Expand Down Expand Up @@ -130,6 +155,7 @@ export function checkQuestion(m: QuestionManifest): string[] {
if (m.kind !== 'cq' && parts.length) problems.push(`${m.kind}: parts are for a creative question`);
for (const l of dup(options.map((o) => o.label))) problems.push(`option ${l}: used twice`);
for (const l of dup(parts.map((p) => p.label))) problems.push(`part ${l}: used twice`);
for (const l of dup((m.provenance ?? []).map((p) => `${p.kind} ${p.code} ${p.year ?? '-'}`))) problems.push(`provenance ${l}: the same paper twice`);
const listed = new Set(m.assets ?? []);
for (const k of questionAssetKeys(m)) if (!listed.has(k)) problems.push(`${k}: used in the text but not in assets`);
return problems;
Expand Down
52 changes: 52 additions & 0 deletions schema/question-manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,58 @@
},
"additionalProperties": {}
},
"provenance": {
"maxItems": 100,
"type": "array",
"items": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]{0,39}$"
},
"code": {
"type": "string",
"pattern": "^[A-Z0-9]{2,8}$"
},
"name": {
"anyOf": [
{
"type": "string"
},
{
"type": "object",
"propertyNames": {
"type": "string",
"pattern": "^[a-z]{2,3}(-[A-Za-z0-9]+)*$"
},
"additionalProperties": {
"type": "string"
}
}
]
},
"year": {
"anyOf": [
{
"type": "integer",
"minimum": 1900,
"maximum": 2200
},
{
"type": "null"
}
]
}
},
"required": [
"kind",
"code",
"name"
],
"additionalProperties": {}
}
},
"assets": {
"type": "array",
"items": {
Expand Down
30 changes: 28 additions & 2 deletions spec/1.7/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,37 @@ Stored and delivered with the media type `application/vnd.chalk.question+json`:
| `answer` | | `text` (the answer, or a worked solution), `detail` (`final` or `full`), and for a number `value` and `unit` |
| `marks`, `difficulty`, `level` | | Marks (a number ≥ 0), difficulty 1 to 5, and the thinking asked for: `knowledge`, `comprehension`, `application`, `higher_order` |
| `subject`, `classes`, `tags`, `license`, `authors` | | For finding questions in a store |
| `source` | | Where it was printed, for people: `book`, `edition`, `number`, `page` |
| `source` | | Where it was printed, for people: `book`, `edition`, `number`, `page`. A publisher that must not say which book it took the question from leaves it out |
| `provenance` | | The exam papers the question was set in, for students (§2.1) |
| `assets` | | The content keys of the pictures the text uses (§4) |

Like the lab manifest, a manifest is an open object: writers keep fields they do not know.

### 2.1 Provenance

Where a question **appeared**: a board's exam, a school's or college's test paper. A reader shows each entry as a short tag
(the `code`) and the full `name` when the tag is hovered or tapped.

```jsonc
"provenance": [
{ "kind": "board_exam", "code": "DHA", "name": "ঢাকা বোর্ড", "year": 2023 },
{ "kind": "institution_exam", "code": "VNSC", "name": "ভিকারুননিসা নূন স্কুল এন্ড কলেজ, ঢাকা", "year": null }
]
```

| Field | | Meaning |
|---|---|---|
| `kind` | required | `board_exam` (a board's public exam) or `institution_exam` (a school's or college's own test paper). Later versions may add kinds: a lower-case letter, then up to 39 lower-case letters, digits and `_` |
| `code` | required | The tag: 2 to 8 of `A`–`Z` and `0`–`9`, such as `DHA` or `VNSC`. A publisher gives each board or school one code and keeps it |
| `name` | required | The full name, as text (§4, without Markdown): a string, or one per language |
| `year` | | The year of the paper, an integer from 1900 to 2200; `null` (or no `year`) when it is not known |

- At most 100 entries. One entry per paper: the same `kind`, `code` and `year` appear once. A question set by two boards in the same year has two entries.
- A writer SHOULD list the newest year first, and entries without a year last. A reader may sort them again.
- A reader MUST NOT reject a manifest because of a `kind` it does not know: it shows the `code`, with the `name` on hover.
- `provenance` is not `source`. `source` is the book the publisher copied the question from, and it may be left out; a paper in
`provenance` is public knowledge, what a student wants to know ("this came in the Dhaka board exam, 2023").

## 3. Kinds

| `kind` | Has | Rules |
Expand Down Expand Up @@ -106,5 +132,5 @@ The manifest's own `tags` are listed as they are. The item's title SHOULD be the
## 7. Conformance

- `schema/question-manifest.schema.json`.
- `examples/questions/`: a multiple-choice, a numeric and a creative question, valid against the schema.
- `examples/questions/`: a multiple-choice, a numeric and a creative question (with `provenance`), valid against the schema.
- A library server accepts the media type, and finds questions by the tags of §5.
Loading