How to create a dynamic exercise bank?

I want to create a dynamic list of solutions to exercises such that when I add a new section and move some problems into it, the new section in solutions list is created with a solution to new problem. In other words I want to:

  • create a function problem(id: "", body: [...], solution: [...]) which will print out the body of the problem, put solution into solutions_bank and create a label so that in other parts of the text the problem could be referenced.
  • create a solutions() function, which will group all solutions from solutions_bank by their chapter.

At first I tried to expand this forum post:
How to keep exercise and solution together in source, but render them separately?
I tried to query all chapters in document and create a list of empty states (one per each chapter) and update them inside problem(...) function call. After failing to compare string and context or string and content I tried to use repr but left that idea after similar tries.

Next i found the exercise-bank package. It almost solved the issue (now I can group solutions by exo-filter() function) but when I tried to automatically assign the topic field value

#let current-chapter-title() = context {
  // Query all level 1 headings that appear before the current location.
  let headings = query(heading.where(level: 1).before(here()))

  // Ensure at least one heading exists.
  if headings == () {
    panic("At least one heading must be defined before the current location.")
  }

  // Return the body (title text) of the last heading found.
  headings.last().body
}

#let problem(title: "", body: [], solution: []) = context {
  exo(
    exercise: body,
    solution: solution,
    topic: repr(current-chapter-title()),
    // topic: "Геометрична алгебра",
  )
}

The topic value is context() instead of the name of current chapter. So, are there any way to create such behaviour? I’m still new to typst, so juggling states and contexts is hard.

read this post, it should help: Why is the value I receive from context always content?

in this case, you are wrapping the return value of current-chapter-title() in context, which means it can’t be read externally anymore. you should get rid of the context keyword for that function and just expect all callers (like problem) to have their own contexts instead. that way, everything is part of a single context block and the callers can read the value

#let current-chapter-title() = {

you should be able to apply this idea to your original custom implementation, in case you’re still interested in doing that (though i would simply use queries, not state). for example:

#let problem(body, sol) = body + [#metadata(sol) <sol>]
#let solutions() = context query(<sol>).map(meta => meta.value).join()

see how the context block is as “big” as we need it to be

1 Like

Thank you! But I wonder, how to group them by section to look like

Geometric algebra

solutions()

Algebraic geometry

solutions()

…etc. Also, is it possible to attach a “label” to the problem, so I could reference it later in text(analogous to figure or equation environment)?

So, the following code works:

#let problem(title, body, sol) = (
  enum.item(body) + [#metadata(sol) <sol>]
)


#let chap_solutions(h) = context {
  let heading_location = query(
    heading.where(level: 1).before(h.at(0).location()),
  )
    .last()
    .location()
  let next_heading_location = h.at(1).location()

  query(selector(<sol>).after(heading_location).before(next_heading_location))
    .map(meta => [+ #meta.value])
    .join()
}

#let solutions() = context {
  let que1 = query(heading.where(level: 1, outlined: true))
  let que2 = query(heading.where(level: 1, outlined: true)).slice(1)

  let que3 = que1.zip(que2)

  que3
    .map(h => [
      #strong(h.at(0).body) #chap_solutions(h) \
    ])
    .join()
}

Produces this:

Now the only question is, how to make each problem referenceable? Hope enum references will arrive at some point.

The things are getting strange

This code utilize the efilrst for enum item referencing

#let section_pairs() = {
  let que = query(heading.where(level: 1, outlined: true))
  let pairs = que.zip(que.slice(1))
  pairs
}



#let location_string(position) = {
  let pair = section_pairs()
    .find(item => (
      item.at(0).location().page() <= position.page()
        and position.page() < item.at(1).location().page()
    ))
    .map(it => str(it.location().page()))
    .join()

  str(pair)
}

#let constraint = efilrst.reflist.with(
  name: "Завдання",
  // counter-name: context location_string(here()),
  list-style: "1.",
  ref-style: "1.1.1",
)

// Almost done, sadly it doesn't converge :/
#let problem(id, body, sol, pos) = context (
  constraint(counter-name: location_string(pos), body, label(id))
    + [#metadata(sol) <sol>]
    + location_string(here())
)

#let chap_solutions(h) = {
  let heading_location = query(
    heading.where(level: 1).before(h.at(0).location()),
  )
    .last()
    .location()
  let next_heading_location = h.at(1).location()

  query(selector(<sol>).after(heading_location).before(next_heading_location))
    .map(meta => [+ #meta.value])
    .join()
}

#let solutions() = context {
  section_pairs()
    .map(h => [
      #strong(h.at(0).body) #chap_solutions(h) \
    ])
    .join()
}

At first it seemed that problem (list of referenceable problems which dynamically generate a list of solutions in dedicated chapter) is solved, but because I call reflist on each problem call, the problem list looks like
1.
1.
1.
etc.
So, I tried to dynamically generate a unique string identifier depending on the current section page number and the next section page number by combining them in a string.

Now the weird part, three calls of problem function are fine, however the fourth one gives layout didn't converge within 5 attempts.

#context {
  problem("geometry", [Знайдіть пучок], [#lorem(10)], here())
  // problem("geometry2", [Знадіть пучок], [#lorem(10)], here())
  problem("geometry3", [#lorem(15)], [#lorem(10)], here())
  problem("geometry4", [#lorem(15)], [#lorem(10)], here())
  problem("geometry5", [#lorem(15)], [#lorem(10)], here())
}

Probably, I end up just writing counter-name every time :confused: