diff --git a/examples/questions/free-fall.question.json b/examples/questions/free-fall.question.json index 81d6c82..add13db 100644 --- a/examples/questions/free-fall.question.json +++ b/examples/questions/free-fall.question.json @@ -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"] } diff --git a/packages/model/src/question-manifest.test.ts b/packages/model/src/question-manifest.test.ts index e153afd..0c2387e 100644 --- a/packages/model/src/question-manifest.test.ts +++ b/packages/model/src/question-manifest.test.ts @@ -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).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([]); diff --git a/packages/model/src/question-manifest.ts b/packages/model/src/question-manifest.ts index b2261c3..c946726 100644 --- a/packages/model/src/question-manifest.ts +++ b/packages/model/src/question-manifest.ts @@ -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(), @@ -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), @@ -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(), @@ -84,6 +108,7 @@ export type QuestionManifest = z.infer; export type QuestionAnswer = z.infer; export type QuestionOption = z.infer; export type QuestionPart = z.infer; +export type QuestionProvenance = z.infer; const KEY_IN_TEXT = /c1:[a-z2-7]{25}[aeimquy4]|sha256:[0-9a-f]{64}/g; @@ -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; diff --git a/schema/question-manifest.schema.json b/schema/question-manifest.schema.json index 6f20dbb..84ff525 100644 --- a/schema/question-manifest.schema.json +++ b/schema/question-manifest.schema.json @@ -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": { diff --git a/spec/1.7/README.md b/spec/1.7/README.md index 8a265eb..1ba06b5 100644 --- a/spec/1.7/README.md +++ b/spec/1.7/README.md @@ -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 | @@ -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.