Asking for guidance on how (not?) to build/generate a random MCQ

Hello, I’m trying to use typst to generate random mcq from a set of questions.
Questions are grouped and each subject is build from a sample of questions from each group, then answers and selected questions are randomized
Currently, I write a subject as follow. The motivation is to be able to render the content of the subject without the generation step.

= Some Group
#metadata((kind: "group", sample: 1))

== Question

Statement

- #metadata((kind: "answer", correct: true)) Answer 1
- #metadata((kind: "answer", correct: false)) Answer 2
...

== Question
...

= Other groupe
...

Then, assuming this hierarchy, a function extract the groups/questions/answers from heading and list. From this data, I sample subjects and rendered all of them.

#show: body => {
  let groups = mcq-extract(body)
  let subjects = mcq-generate-subjects(groups, seed)

  for subject in subjects {
    let doc = mcq-format-subject(subject)

    counter(page).update(1)
    doc
    pagebreak(to: "odd")
  }
}

The directions I follow are :

  • avoid state and context if possible
  • the subject should render without the show rule
  • final results should looks like Overview :: AMC and I must be able to extract all needed geometrical information (basically, all box positions and sizes

I successfully conduct a mcq but I feel I’m doing things wrong :

  • the extraction is highly dependent on the document structure
  • I cannot style per questions
  • the show rule basically rewrites the whole document

I’m asking for guidance, how you would do, how you definitely won’t do…

This is how I would do it. Define the data separately (in Typst markup to retain all formatting possibilities) and have functions that display groups and questions.

Using suiji is the only way of that I know to randomize things within Typst so I’ve used it here. It does make the functions a bit more complicated, hopefully it isn’t too confusing.

#import "@preview/suiji:0.5.1": *
#let rng = gen-rng-f(42)

/*----------------------------------------------------------------------------*/
/*                               Question Data                                */
/*----------------------------------------------------------------------------*/
#let group1 = (
  name: "Animals",
  questions: (
    (
      title: [Elephant Color],
      body: [What color is an _elephant_?],
      answer: box(outset: 3pt, fill: gray.opacify(50%))[grey],
      wrong-answers: (
        box(outset: 3pt, fill: red.lighten(50%))[red],
        box(outset: 3pt, fill: black.lighten(50%))[black],
        box(outset: 3pt, fill: yellow.lighten(50%))[yellow],
      )
    ),
    (
      title: [Lion Color],
      body: [What color is a _lion_?],
      answer: box(outset: 3pt, fill: yellow.lighten(50%))[yellow],
      wrong-answers: (
        box(outset: 3pt, fill: gray.opacify(50%))[grey],
        box(outset: 3pt, fill: red.lighten(50%))[red],
        box(outset: 3pt, fill: black.lighten(50%))[black],
      )
    ),
  ),
)

/*----------------------------------------------------------------------------*/
/*                                 Functions                                  */
/*----------------------------------------------------------------------------*/
#let display-question(local-rng, number, q) = {
  assert(type(q) == type((:)), message: "The given question `q` must be of type dictionary")
  assert(q.keys().contains("title"))
  assert(q.keys().contains("body"))
  assert(q.keys().contains("answer"))
  assert(q.keys().contains("wrong-answers"))
  assert(type(q.wrong-answers) == type(()))

  //Randomize answers
  let all-answers = {q.wrong-answers + (q.answer, )}.flatten()
  (local-rng, all-answers) = shuffle(local-rng, all-answers)

  let body = {
    heading(level: 2)[Question #number - #q.title]
    q.body
  
    align(
      center,
      
      stack(
        dir: ltr,
        spacing: 1em,
        ..all-answers.map(q => [☐ #q])
      )
    )
  }

  return (local-rng, body)
}

#let display-group(local-rng, g) = {
  assert(g.keys().contains("name"))
  assert(g.keys().contains("questions"))
  assert(type(g.questions) == type(()))

  let body = {
    heading(level: 1)[Group - #g.name]
    let body = none
  
    for (i, q) in g.questions.enumerate() {
      (local-rng, body) = display-question(local-rng, + 1, q)
      body
    }
  }

  return (local-rng, body)
}

/*----------------------------------------------------------------------------*/
/*                               Main Document                                */
/*----------------------------------------------------------------------------*/
#outline()

#let body-group1 = none
#{(rng, body-group1) = display-group(rng, group1)}
#body-group1

Some further steps that I would take is to separate this into different files. The functions in one file, each group would get its own file, the main document would get a file, and a template for more basic styling.

3 Likes