Manual de uso de qbank
1. Introducción
qbank es una librería Python para generar bancos de preguntas de opción múltiple
cuyas respuestas correctas se determinan automáticamente mediante cálculo
proposicional. A partir de una lista de supuestos y cuestiones, genera todas las
variantes posibles de una pregunta combinando los elementos alternativos; para cada
variante calcula qué respuestas son correctas y cuáles no, y exporta el resultado a
LaTeX/AMC (exámenes en papel) o a Moodle. Se apoya en
calcprop, el motor de cálculo proposicional.
Este documento es la referencia: la API, el formato JSON, los exportadores y el
comprobador lint, con sus firmas exactas. No es el sitio por donde empezar:
README.orgexplica cómo instalar y cómo crear un banco conqbank-init.Tutorial.ipynbrecorre cinco ejemplos progresivos ejecutables.- La Guía de diseño (
GuiaDiseno.org, impresa enGuiaDiseno.pdf) explica cómo diseñar buenas preguntas: la mecánica de listas y mundos, el patrón recomendado, la redacción y las trampas de LaTeX y Moodle.
Los ejemplos ejecutables de este manual cargan los JSON de la carpeta
ejemplosManual/, que está en el repositorio pero no viene con pip install. Para
ejecutarlos, clona el repositorio
(git clone https://github.com/mbujosab/calcprop-qbank) y lánzalos desde su
carpeta raíz. Para crear tu banco no hace falta: qbank-init trae sus propias
preguntas de ejemplo.
El vocabulario que usa este manual es el de la Guía: variante (cada versión concreta
de la pregunta), mundo (las hipótesis activas en una variante), hueco (cada
posición que se convierte en una opción: una Cuestion suelta o una lista de la que
se sortea una), parte (cada bloque de opciones de una pregunta con varios),
opción por defecto («Ninguna de las anteriores», que añade export.last_choice)
y vista profe (todas las cuestiones de cada hueco con sus marcas).
1.1. Instalación rápida (para quien ya conoce Python)
pip install calcprop-qbank
qbank-init y lint llegan con la 0.6.0; si tenías una versión anterior,
actualiza con pip install -U calcprop-qbank. La versión en desarrollo se instala
desde el repositorio (hace falta git):
pip install "calcprop-qbank[jupyter] @ git+https://github.com/mbujosab/calcprop-qbank"
Si no estás familiarizado con Python o los entornos virtuales, lee la sección siguiente.
2. Instalación paso a paso
2.1. Requisitos previos
qbank requiere Python 3.9 o superior. Comprueba si ya lo tienes instalado:
| Sistema | Comando en terminal |
|---|---|
| Linux | python3 --version |
| macOS | python3 --version |
| Windows | py --version |
Si el comando devuelve algo como Python 3.11.2 estás listo. Si no, descarga Python
desde python.org e instálalo (en Windows marca la opción Add Python to PATH).
2.2. Qué es un entorno virtual y por qué usarlo
Un entorno virtual es una carpeta que contiene una copia aislada de Python con sus propios paquetes instalados, independiente del resto del sistema. Esto evita conflictos entre proyectos que requieren versiones distintas de las mismas librerías.
Trabajar siempre dentro de un entorno virtual es una buena práctica, aunque no es
estrictamente obligatorio para un uso sencillo de qbank.
2.3. Crear y activar un entorno virtual
Abre una terminal (en Windows: Símbolo del sistema o PowerShell) y sitúate en el directorio donde quieres trabajar, por ejemplo:
mkdir mis_cuestionarios cd mis_cuestionarios
Crea el entorno virtual (llámalo como quieras; aquí usamos .venv):
| Sistema | Comando |
|---|---|
| Linux / macOS | python3 -m venv .venv |
| Windows (cmd) | py -m venv .venv |
| Windows (PS) | py -m venv .venv |
Actívalo:
| Sistema | Comando |
|---|---|
| Linux / macOS | source .venv/bin/activate |
| Windows (cmd) | .venv\Scripts\activate.bat |
| Windows (PowerShell) | .venv\Scripts\Activate.ps1 |
Cuando el entorno está activo, verás su nombre entre paréntesis al comienzo del
prompt, por ejemplo (.venv). A partir de ese momento, pip y python apuntan
al entorno virtual y no al sistema.
2.4. Instalar calcprop-qbank
Con el entorno activo:
pip install calcprop-qbank
o, para la versión en desarrollo (hace falta tener git):
pip install "calcprop-qbank[jupyter] @ git+https://github.com/mbujosab/calcprop-qbank"
Verifica la instalación:
python -c "import qbank; print('qbank', qbank.__version__)"
qbank-init --help
Si ves qbank 0.6.0 (o posterior) y la ayuda de qbank-init, todo está en orden.
Si en cambio sale AttributeError: … no attribute '__version__' o qbank-init no
existe, tienes una versión anterior a la 0.6: actualiza con
pip install -U calcprop-qbank.
Para los PDF y el XML de Moodle hace falta
además pdflatex con los paquetes automultiplechoice (lo instala el programa
Auto Multiple Choice) y moodle (tlmgr install moodle en TeX Live).
2.5. Nota para usuarios de Windows
En Windows, dentro del entorno virtual, usa python (no python3). Si al activarlo
en PowerShell (.venv\Scripts\Activate.ps1) sale un error de permisos, ejecuta antes:
Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
2.6. Crear un banco de preguntas
La forma recomendada de trabajar es un proyecto creado con qbank-init:
qbank-init mi-banco cd mi-banco make lint # comprueba las preguntas de ejemplo (sin LaTeX) make amc # PDF de revisión (vista profe) en build/amc/
El proyecto contiene cinco preguntas de ejemplo, los scripts que las exportan, un
Makefile, nueva_pregunta.py (un guion para escribir una pregunta en Python y
guardarla como JSON) y, en prompts/, instrucciones para escribir preguntas con
ayuda de una IA. Su README.org explica el flujo completo. El resto de este
manual describe las piezas que esos scripts usan.
3. Conceptos fundamentales
3.1. Fórmulas con calcprop
qbank importa calcprop, así que al escribir from qbank import * tienes acceso al
motor lógico. Las variables proposicionales (en la Guía, etiquetas) se crean con
v() y los nombres los eliges tú; solo tienen que ser consistentes dentro de la
misma pregunta:
from qbank import *
A = v('A') # variable proposicional A
Las fórmulas se construyen combinando variables con los operadores de Python:
| Operador Python | Conectiva lógica | Ejemplo |
|---|---|---|
-v('A') |
Negación (¬A) | -v('A') |
v('A') & v('B') |
Conjunción (A \(\land\) B) | v('A') & v('B') |
v('A') | v('B') |
Disyunción (A \(\lor\) B) | v('A') | v('B') |
v('A') >> v('B') |
Implicación (A \(\implies\) B) | v('A') >> v('B') |
v('A') ** v('B') |
Bicondicional (A \(\iff\) B) | v('A') ** v('B') |
alguno(v('A'), v('B'), v('C')) |
Alguna verdadera | alguno(v('A'), v('B'), v('C')) |
unoDe(v('A'), v('B'), v('C')) |
Exactamente una verdadera | unoDe(v('A'), v('B'), v('C')) |
La semántica también acepta True (siempre verdadero) o False (siempre falso).
alguno y unoDe aceptan cualquier número de proposiciones.
Atención — precedencia de operadores: la precedencia de Python difiere de la lógica
matemática. En lógica, → tiene menor precedencia que ∧ y ∨, por lo que A ∧ B → C
se lee como (A ∧ B) → C. En Python, >> tiene mayor precedencia que & y |,
así que v('A') & v('B') >> v('C') se evalúa como v('A') & (v('B') >> v('C')), que
no es lo que se espera. Usa siempre paréntesis explícitos en implicaciones compuestas:
(v('A') & v('B')) >> v('C') # correcto: (A ∧ B) → C
v('A') & v('B') >> v('C') # incorrecto: A ∧ (B → C)
La función test(formula, hipotesis) comprueba si una fórmula es consecuencia lógica
de una lista de fórmulas hipótesis:
test(v('C'), [v('A'), v('A') >> v('C')]) # True (modus ponens)
test(v('C'), [v('B')]) # False (B no implica C)
test(True, []) # True
test(False, []) # False
No necesitas llamar a test directamente: qbank lo invoca al generar las variantes.
Conviene tener presente lo que responde: «¿se deduce esto de las hipótesis?», no «¿es
verdad?». De la hipótesis v('crec') no se sigue -v('decr'), salvo que un axioma
declare que ambas son excluyentes (unoDe(v('crec'), v('decr'))).
3.2. Supuesto — una hipótesis del enunciado
Un Supuesto es una afirmación que se añade al enunciado del problema y cuya semántica
se incorpora como hipótesis para evaluar las cuestiones que le siguen.
Supuesto(enunciado, semantica, precond=True)
| Parámetro | Tipo | Descripción |
|---|---|---|
enunciado |
cadena | Texto LaTeX del supuesto, tal como aparecerá impreso. Admite @{variable} (ver setup). |
semantica |
fórmula | Fórmula proposicional que el supuesto afirma verdadera. |
precond |
fórmula/bool | El supuesto solo se activa si esta precondición se cumple bajo las hipótesis ya acumuladas. Por defecto True (siempre activo). |
Un Supuesto con enunciado vacío es un axioma: añade su fórmula sin texto.
Supuesto('$\\mathcal{A}$ es verdadero. ', v('A'))
Supuesto('$\\mathcal{A} \\Rightarrow \\mathcal{C}$. ', v('A') >> v('C'))
Supuesto('', unoDe(v('inv'), v('sing'))) # axioma: los dos mundos son excluyentes
Si la precondición de un supuesto no se cumple en una variante concreta, esa variante se descarta.
3.3. Cuestion — un ítem de respuesta
Una Cuestion es una posible respuesta. qbank determina si es correcta o no según las
hipótesis activas en cada variante.
Cuestion(enunciado, semantica, precond=True, exp="")
| Parámetro | Tipo | Descripción |
|---|---|---|
enunciado |
cadena | Texto LaTeX del ítem de respuesta. Admite @{variable}. |
semantica |
fórmula/bool | Fórmula cuya veracidad (como consecuencia de las hipótesis) determina si el ítem es correcto. |
precond |
fórmula/bool | La cuestión solo aparece si esta precondición se satisface; si no, la variante se descarta. Por defecto True. |
exp |
cadena | Explicación (retroalimentación) que Moodle muestra tras responder. Admite @{variable}. Opcional. |
Cuestion("Entonces $\\mathcal{C}$ es verdadero", v("C"))
Cuestion("Esta afirmación es siempre falsa", False)
Cuestion("$\\mathcal{B}$ es consecuencia de $\\mathcal{A}$", v("B"), precond=v("A"))
Cuestion("$f(@{a}) < f(@{b})$", v("crec"), exp="Porque $@{a} < @{b}$ y $f$ es creciente.")
Semánticas y precondiciones como cadenas. Tanto Supuesto como Cuestion aceptan
semantica y precond escritas como la cadena que las representa ("v('A') >> v('B')",
"True", "lambda ns: ns['x'] > 0"), que es exactamente lo que guarda el formato
JSON. Es la forma de escribir una lambda en Python si la pregunta se va a guardar con
save_problema: una función Python no se puede serializar; su cadena, sí.
3.4. ProblemaTipo — un generador de variantes
ProblemaTipo toma una lista plana de componentes y genera todas las combinaciones
posibles, una variante por cada combinación.
ProblemaTipo(componentes, setup=None, export=None, seed=None, nombre=None)
| Parámetro | Descripción |
|---|---|
componentes |
Lista plana de cadenas (texto fijo), Supuesto y Cuestion, sueltos o en listas de alternativas (ver abajo). |
setup |
Código Python como cadena (lo que guarda el JSON) o función sin argumentos que devuelve un dict. Se ejecuta antes de cada variante. |
export |
Dict con las preferencias de exportación: last_choice, cols, instances. Lo leen los scripts de un banco, no las funciones. |
seed |
Entero que siembra numpy.random y random antes de recorrer las variantes (solo tiene sentido con setup aleatorio). |
nombre |
Identificador del problema (el campo nombre del JSON; debe coincidir con el nombre del fichero). |
Las funciones de exportación no leen export: pasa tú last_choice=, instances= y
cols= a AMCblock, QuizMoodle, QuizAMCProfe y las demás (los ejemplos de este
manual lo hacen). Los scripts de un banco de qbank-init sí lo leen de cada JSON.
3.4.1. La mecánica: listas = sorteo, elementos consecutivos = opciones
Cada componente es, o bien un valor suelto (siempre se incluye), o bien una lista de dos o más valores del mismo tipo, de la que se sortea uno en cada variante. El número de variantes es el producto de las longitudes de las listas.
- Una lista de
Supuestoson mundos alternativos: se activa uno por variante. - Una lista de
Cuestionno son «las opciones que se muestran juntas»: es un hueco del que se sortea una. El alumno ve tantas opciones como elementos-Cuestionconsecutivos haya (sueltos o listas), más la opción por defecto siexport.last_choiceestá activo. - Cuando tras un bloque de cuestiones vuelve a aparecer texto o un
Supuesto, se abre una parte nueva (una pregunta con varios bloques de opciones). Las hipótesis se acumulan a lo largo de las partes.
El patrón recomendado es tres huecos + "export": {"last_choice": true}: cuatro
opciones, con la garantía de que al menos una es correcta y al menos una incorrecta.
La Guía desarrolla todo esto; aquí basta un ejemplo completo.
3.4.2. Ejemplo
from qbank import *
p = ProblemaTipo(
[
"Considere ",
[ # dos mundos: se sortea uno
Supuesto("$\\mathcal{A}$ ", v("A")),
Supuesto("$\\mathcal{B}$ ", v("B")),
],
[ # y otros dos, independientes de los anteriores
Supuesto("y que $\\mathcal{A}\\Rightarrow\\mathcal{C}$. ", v("A") >> v("C")),
Supuesto("y que $\\mathcal{B}\\Leftrightarrow\\mathcal{D}$. ", v("B") ** v("D")),
],
"Entonces, con toda seguridad: ",
[ # hueco 1
Cuestion("se da $\\mathcal{C}$", v("C")),
Cuestion("se da $\\mathcal{D}$", v("D")),
],
[ # hueco 2
Cuestion("se da $\\mathcal{A}$", v("A")),
Cuestion("se da $\\mathcal{B}$", v("B")),
],
[ # hueco 3
Cuestion("se dan $\\mathcal{A}$ y $\\mathcal{C}$", v("A") & v("C")),
Cuestion("se dan $\\mathcal{B}$ y $\\mathcal{D}$", v("B") & v("D")),
Cuestion("se da $\\mathcal{E}$", False),
],
],
export={"last_choice": True, "cols": 2},
nombre="ejercicio",
)
for etiqueta, partes in p.por_partes():
for enunciado, cuestiones in partes:
print(f"Variante {etiqueta}: {enunciado}")
for texto, correcto, activa, exp in cuestiones:
print(f" {'✓' if correcto else '✗'} {texto}")
if int(etiqueta) == 3:
break
print("…", len(list(p.por_partes())), "variantes en total")
Variante 1: Considere $\mathcal{A}$ y que $\mathcal{A}\Rightarrow\mathcal{C}$. Entonces, con toda seguridad:
✓ se da $\mathcal{C}$
✓ se da $\mathcal{A}$
✓ se dan $\mathcal{A}$ y $\mathcal{C}$
Variante 2: Considere $\mathcal{A}$ y que $\mathcal{A}\Rightarrow\mathcal{C}$. Entonces, con toda seguridad:
✓ se da $\mathcal{C}$
✓ se da $\mathcal{A}$
✗ se dan $\mathcal{B}$ y $\mathcal{D}$
Variante 3: Considere $\mathcal{A}$ y que $\mathcal{A}\Rightarrow\mathcal{C}$. Entonces, con toda seguridad:
✓ se da $\mathcal{C}$
✓ se da $\mathcal{A}$
✗ se da $\mathcal{E}$
… 48 variantes en total
\(2 \times 2 \times 2 \times 2 \times 3 = 48\) variantes, cada una con tres opciones
más «Ninguna de las anteriores» (que el exportador añade y que en la variante 1 será
incorrecta). ejemplosManual/ejercicio.json guarda este mismo problema en JSON, con
una explicación (exp) en cada cuestión que aquí se omite por brevedad.
3.4.3. Iterar las variantes
por_partes(instances=1, base_seed=None, verbose=False)- la vía recomendada.
Devuelve
(etiqueta, [(enunciado, cuestiones), …]), un par por parte;cuestioneses una lista de tuplas(texto, correcto, activa, exp). Consetupaleatorio,instances=Nrecorre las variantes \(N\) veces con semillas distintas (base_seed + i * 997; si no se pasabase_seedse usaseed, y si tampoco hayseed, 42). Coninstances=1y sin ninguna de las dos no se siembra nada: el resultado depende del estado del generador. Converbose=Trueimprime los supuestos y cuestiones rechazados por su precondición. por_partes_profe(verbose=True)- la vista profe: el mismo formato, pero con
todas las cuestiones de cada hueco (no una por variante) y las rechazadas por su
precondición marcadas como
'rechazada por …'en vez de descartar la variante. Es lo que conviene revisar antes de dar una pregunta por buena. for etiqueta, enunciado, cuestiones in p- el protocolo histórico, solo para
ejercicios de una parte (con varias lanza
ValueError).
3.5. Preguntas paramétricas con setup
El parámetro setup permite que los valores numéricos o simbólicos cambien en cada
variante. En cada iteración:
- Se ejecuta el
setupy sus variables forman el espacio de nombresnsde la variante. - Los textos (enunciados, cuestiones y
exp) que contengan marcadores@{variable}reciben la sustitución. - Las semánticas y precondiciones que sean
lambda ns: ...se evalúan con ese diccionario.
El delimitador @ se eligió para evitar conflictos con el modo matemático de \LaTeX{}.
Para una arroba literal, escribe @@.
3.5.1. setup como cadena, np.random y seed
Escribe el setup como cadena de código (es lo que guarda el JSON, y la única forma
que save_problema puede serializar) y usa np.random, no random: seed siembra
numpy.random y random, pero los scripts de la vista profe de un banco solo siembran
numpy.
p = ProblemaTipo(
[
"Sean $a = @{a}$ y $b = @{b}$. Entonces:",
[
Cuestion("$a + b = @{suma}$", True),
Cuestion("$a + b = @{suma_falsa}$", False, exp="$@{a} + @{b} = @{suma}$."),
],
[
Cuestion("$a + b > 10$", "lambda ns: ns['a'] + ns['b'] > 10"),
Cuestion("$a + b < 10$", "lambda ns: ns['a'] + ns['b'] < 10"),
],
Cuestion("$a \\cdot b = @{producto}$", True),
],
setup="import numpy as np\n"
"a, b = (int(x) for x in np.random.randint(1, 10, 2))\n"
"suma, suma_falsa, producto = a + b, a + b + 1, a * b",
seed=1,
export={"last_choice": True, "instances": 15},
)
@{a},@{b},@{suma}se interpolan con los valores de cada variante.- Las lambdas, escritas como cadena, se evalúan con los valores de la variante.
seed=1hace la pregunta reproducible;instances=15recorre 15 veces (15 instancias) las 4 combinaciones (60 variantes, el mínimo que recomiendalintconsetup).
El iterador es finito aunque el setup sea aleatorio: recorre una vez cada combinación
de alternativas y llama al setup en cada una. Para obtener más instancias se usa
instances, no un bucle a mano.
3.5.2. Precondiciones que dependen de los valores
El motor razona de forma proposicional y no conoce los valores numéricos: si
ofreciéramos los supuestos «\(a > b\)» y «\(a < b\)» sin más, podría generar una variante con
a = 3, b = 7 y el texto «Se sabe que \(a > b\)». La solución es una precondición
lambda ns: ... que solo deja pasar la variante cuando la afirmación es cierta para los
valores sorteados:
ordenar = ProblemaTipo(
[
"Sean $a = @{a}$ y $b = @{b}$ dos números naturales. Se sabe que ",
[
Supuesto("$a > b$. ", True, precond="lambda ns: ns['a'] > ns['b']"),
Supuesto("$a < b$. ", True, precond="lambda ns: ns['a'] < ns['b']"),
Supuesto("$a = b$. ", True, precond="lambda ns: ns['a'] == ns['b']"),
],
"Indique qué afirmación es correcta: ",
[
Cuestion("$a - b > 0$", "lambda ns: ns['a'] - ns['b'] > 0"),
Cuestion("$b - a > 0$", "lambda ns: ns['b'] - ns['a'] > 0"),
],
[
Cuestion("$a^2 > b^2$", "lambda ns: ns['a'] ** 2 > ns['b'] ** 2"),
Cuestion("$a^2 < b^2$", "lambda ns: ns['a'] ** 2 < ns['b'] ** 2"),
],
Cuestion("$a \\cdot b > 0$", True),
],
setup="import numpy as np\na, b = (int(x) for x in np.random.randint(2, 10, 2))",
seed=7,
export={"last_choice": True, "instances": 15},
)
Para cada combinación el setup genera una tirada nueva; si su precondición no se
cumple, esa variante se descarta (con verbose=True verás, por ejemplo, el mensaje
Supuesto: … rechazado por …). Por eso el número de variantes por instancia no es fijo:
de las \(3 \times 2 \times 2 = 12\) combinaciones sobreviven unas cinco por instancia
(las del supuesto que coincide con la tirada), y lint calcula que hacen falta 14
instancias para llegar a 60 (con las 15 de export salen 69). El problema
ejemplosManual/numeros.json (el que carga el editor visual del cuaderno
Demo.ipynb) es una versión ampliada de esta idea.
Con precondiciones numéricas, por_partes_profe() hace un solo sorteo por mundo, y
un mundo cuya precondición no se cumple en ese sorteo no aparece: con seed=7,
ordenar.por_partes_profe() muestra los tres mundos, pero con otra semilla podría
mostrar menos (con seed=2, ninguno). La vista profe de un banco (make amc) hace
varios sorteos para no perder mundos.
3.5.3. seed: reproducibilidad independiente del orden
Cuando varios problemas con setup aleatorio se generan en secuencia (en un bucle o
un Makefile), los valores sorteados en cada problema dependerían del estado del
generador en ese momento. El campo seed (entero) resuelve esto: antes de iterar las
variantes, qbank siembra numpy.random y random con él. Así cada problema tiene su
propia secuencia reproducible. También es la semilla base de por_partes(instances=N)
cuando no se pasa base_seed.
3.5.4. setup como función (solo uso interactivo)
setup admite también una función sin argumentos que devuelve el diccionario de
variables. Es cómodo en un cuaderno, pero un problema así no puede guardarse con
save_problema (el código de una función no es recuperable); lo mismo ocurre con las
lambdas escritas como funciones Python en semantica o precond. Escríbelos como
cadenas si la pregunta va a vivir en un fichero.
4. El formato JSON
qbank convierte cualquier ProblemaTipo a JSON y lo reconstruye después. Es el
formato en que se guardan las preguntas de un banco (lo que leen lint y los scripts
de qbank-init).
4.1. Campos
{
"version": "1",
"tipo": "ProblemaTipo",
"nombre": "L-01-Monotonia",
"seed": 1,
"setup": "import numpy as np\na = int(np.random.randint(1, 10))",
"export": {"last_choice": true, "cols": 2, "instances": 6},
"componentes": [
"Texto fijo ",
{"tipo": "Supuesto", "enunciado": "", "semantica": "unoDe(v('A'), v('B'))", "precond": "True"},
[
{"tipo": "Supuesto", "enunciado": "alternativa 1 ", "semantica": "v('A')", "precond": "True"},
{"tipo": "Supuesto", "enunciado": "alternativa 2 ", "semantica": "v('B')", "precond": "True"}
],
" Entonces:",
[
{"tipo": "Cuestion", "enunciado": "¿A?", "semantica": "v('A')", "precond": "True", "exp": ""},
{"tipo": "Cuestion", "enunciado": "¿B?", "semantica": "v('B')", "precond": "True", "exp": ""}
],
{"tipo": "Cuestion", "enunciado": "$a = @{a}$", "semantica": "True", "precond": "True", "exp": ""}
]
}
| Clave | Descripción |
|---|---|
version |
"1". |
tipo |
"ProblemaTipo" (o "ProblemaVF", ver el apéndice). |
nombre |
Identificador; debe coincidir con el nombre del fichero sin .json y no contener : (rompe make). |
seed |
Entero, null o ausente. Solo con setup aleatorio. |
setup |
Código Python como cadena (saltos de línea como \n) o null. |
export |
Opcional. last_choice (bool): opción por defecto. cols (int): columnas en AMC. instances (int): instancias con setup aleatorio. |
componentes |
La lista plana: cadenas, objetos Supuesto y Cuestion y listas de ellos. |
Cada Supuesto es un objeto con tipo, enunciado, semantica y precond; cada
Cuestion añade exp. Las fórmulas y lambdas van como cadenas, exactamente como se
escribirían en Python. Las barras de LaTeX se doblan (\\mathcal).
Puedes añadir claves propias de nivel superior, como "_nivel": "M" o "_tema": qbank
las ignora al cargar y save_problema las conserva.
Regla para instances: con setup aleatorio, instances = max(1, ceil(60 / variantes
por instancia)), para que la vista alumno tenga al menos 60 variantes; lint imprime el
valor necesario.
4.2. De lista de listas a JSON y vuelta
problema_to_dict devuelve el dict y save_problema lo escribe en un fichero;
load_problema acepta una ruta o un dict y devuelve un ProblemaTipo listo para iterar.
import os, tempfile ruta = os.path.join(tempfile.gettempdir(), "ordenar.json") # p. ej. preguntas/ordenar.json save_problema(ordenar, ruta) p2 = load_problema(ruta) # o load_problema(dict) assert problema_to_dict(p2) == problema_to_dict(ordenar)
(El ejemplo guarda en un directorio temporal para no tocar los JSON de
ejemplosManual/, que usan las secciones siguientes.)
Para bancos de varios problemas en un solo fichero, save_banco(lista, ruta) y
load_banco(ruta); los proyectos de qbank-init usan en cambio un fichero por pregunta.
4.3. Un ejemplo con setup en JSON
El fichero ejemplosManual/matriz_rango.json:
{
"version": "1",
"tipo": "ProblemaTipo",
"nombre": "matriz_rango",
"seed": 7,
"setup": "import numpy as np\nn = int(np.random.randint(3, 6))\nk = int(np.random.randint(2, n))",
"export": {"last_choice": true, "cols": 2, "instances": 4},
"componentes": [
"Considere una matriz $A$ de $@{n}$ filas y $@{k}$ columnas. ",
[
{"tipo": "Supuesto", "enunciado": "Suponga que $A$ tiene rango máximo ($\\mathrm{rg}(A)=@{k}$). ",
"semantica": "v('MaxRk')", "precond": "True"},
{"tipo": "Supuesto", "enunciado": "Suponga que $A$ tiene rango deficiente ($\\mathrm{rg}(A)<@{k}$). ",
"semantica": "-v('MaxRk')", "precond": "True"}
],
"Indique qué afirmaciones son correctas: ",
[
{"tipo": "Cuestion", "enunciado": "Las $@{k}$ columnas de $A$ son linealmente independientes.",
"semantica": "v('MaxRk')", "precond": "True", "exp": "…"},
{"tipo": "Cuestion", "enunciado": "Alguna columna de $A$ es combinación lineal de las demás.",
"semantica": "-v('MaxRk')", "precond": "True", "exp": "…"}
],
"… (otros dos huecos: $A^\\top A$ invertible / singular; $Ax = 0$ con solución única / infinitas)"
]
}
p = load_problema("ejemplosManual/matriz_rango.json")
for etiqueta, partes in p.por_partes():
for enunciado, cuestiones in partes:
print(f"Variante {etiqueta}: {enunciado}")
for texto, correcto, activa, exp in cuestiones:
print(f" {'✓' if correcto else '✗'} {texto}")
if int(etiqueta) == 2:
break
print(len(list(p.por_partes())), "variantes por instancia;",
len(list(p.por_partes(instances=4))), "con instances=4")
Variante 1: Considere una matriz $A$ de $3$ filas y $2$ columnas. Suponga que $A$ tiene rango máximo ($\mathrm{rg}(A)=2$). Indique qué afirmaciones son correctas:
✓ Las $2$ columnas de $A$ son linealmente independientes.
✓ $A^\top A$ es invertible.
✓ El sistema $Ax = 0$ tiene solo la solución trivial.
Variante 2: Considere una matriz $A$ de $4$ filas y $2$ columnas. Suponga que $A$ tiene rango máximo ($\mathrm{rg}(A)=2$). Indique qué afirmaciones son correctas:
✓ Las $2$ columnas de $A$ son linealmente independientes.
✓ $A^\top A$ es invertible.
✗ El sistema $Ax = 0$ tiene infinitas soluciones.
16 variantes por instancia; 64 con instances=4
La lógica proposicional y los valores numéricos actúan de forma independiente:
v('MaxRk') no «sabe» cuánto valen \(n\) y \(k\); su valor de verdad lo determina el
supuesto elegido. El setup solo aporta los números que aparecen en el texto. El
segundo supuesto afirma -v('MaxRk') directamente, y por eso las cuestiones con esa
semántica se deducen sin necesidad de un axioma unoDe.
4.4. Exportar la lista de listas a un fichero .py editable
problema_to_python devuelve el código Python que recrea el problema como listas
editables, y save_problema_py lo escribe en un fichero. El resultado se ejecuta tal
cual y puede volver a guardarse con save_problema: si el problema tiene setup, su
código aparece en la cadena _SETUP, y las lambdas van entre comillas.
codigo = problema_to_python(load_problema("ejemplosManual/ejercicio.json"))
print("\n".join(codigo.splitlines()[:30])) # las primeras 30 líneas
from qbank import Supuesto, Cuestion, ProblemaTipo
from calcprop import *
ejercicio = [
r'Considere ',
[
Supuesto(
enunciado = r'$\mathcal{A}$ ',
semantica = v('A'),
precond = True
),
Supuesto(
enunciado = r'$\mathcal{B}$ ',
semantica = v('B'),
precond = True
),
],
[
Supuesto(
enunciado = r'y que $\mathcal{A}\Rightarrow\mathcal{C}$. ',
semantica = v('A') >> v('C'),
precond = True
),
Supuesto(
enunciado = r'y que $\mathcal{B}\Leftrightarrow\mathcal{D}$. ',
semantica = v('B') ** v('D'),
precond = True
),
],
r'Entonces, con toda seguridad: ',
4.5. Los ejemplos de ejemplosManual/
Cinco ficheros ilustran, de forma acumulativa, las mecánicas de este capítulo, y
Tutorial.ipynb los recorre ejecutándolos: ej_a_literales (valores de verdad
literales, tres huecos), ej_b_mundos (una lista de Supuesto), ej_c_precond
(axioma unoDe y precond por mundo), ej_d_setup_vars (setup, @{var}, seed,
instances y exp dinámico) y ej_e_lambda (semántica lambda que inyecta fórmulas).
Los otros cuatro (ejercicio, matriz_rango, numeros y superheroes) son los que
usan este manual y Demo.ipynb. Todos pasan lint sin ERROR ni AVISO.
5. Exportar a AMC (LaTeX)
Las funciones AMC generan el código LaTeX para
Auto Multiple Choice: cada una devuelve una
cadena con un bloque \element{...}, que el usuario escribe al fichero. El flujo
completo (un .tex por pregunta, un documento que lo compila y los PDF de revisión) lo
hacen los scripts de un proyecto qbank-init (make amc y make amc-student).
Las funciones no leen export del problema: pasa last_choice= y cols= en cada
llamada (los scripts de un banco sí lo leen y se los pasan).
5.1. AMCblock
AMCblock(nombre, etiqueta, enunciado, cuestiones,
last_choice=False, cols=1, profe=False, *,
aux_latex="", last_choice_text="Ninguna de las anteriores")
| Parámetro | Descripción |
|---|---|
nombre |
Nombre del grupo AMC (etiqueta LaTeX). Los : y _ se convierten en -. |
etiqueta |
Identificador único de esta variante dentro del grupo. |
enunciado |
Texto del enunciado ($$...$$ se convierte a \[...\], que automultiplechoice prefiere). |
cuestiones |
Lista de tuplas (texto, correcto, activa[, exp]), la segunda componente de cada parte. |
last_choice |
Si True, añade la opción por defecto con \lastchoices: correcta solo si las demás son falsas. |
cols |
Columnas (>1 activa \begin{multicols}{N}\AMCBoxedAnswers). |
profe |
Si True, vista profe: las cuestiones rechazadas por su precondición van en \explain{}. |
aux_latex |
Instrucciones LaTeX extra dentro del bloque \element{}. |
last_choice_text |
Texto de la opción por defecto. |
p = load_problema("ejemplosManual/ejercicio.json")
etiqueta, partes = next(iter(p.por_partes()))
enunciado, cuestiones = partes[0]
print(AMCblock("MiCuestionario", etiqueta, enunciado, cuestiones, last_choice=True, cols=2))
\element{MiCuestionario}{
\begin{questionmult}{MiCuestionario-1}
Considere $\mathcal{A}$ y que $\mathcal{A}\Rightarrow\mathcal{C}$. Entonces, con toda seguridad:
\begin{multicols}{2}\AMCBoxedAnswers
\begin{choices}
\correctchoice{se da $\mathcal{C}$}
\correctchoice{se da $\mathcal{A}$}
\correctchoice{se dan $\mathcal{A}$ y $\mathcal{C}$}
\lastchoices
\wrongchoice {Ninguna de las anteriores}
\end{choices}
\end{multicols}
\end{questionmult} }
Para escribir todas las variantes de la vista alumno:
with open("preguntas.tex", "w") as f:
for etiqueta, partes in p.por_partes():
enunciado, cuestiones = partes[0]
f.write(AMCblock("MiCuestionario", etiqueta, enunciado, cuestiones, last_choice=True, cols=2))
5.2. AMC_multipart — preguntas con varias partes
Un AMCblock por parte produciría dos preguntas con el mismo identificador y la
segunda sin las hipótesis. AMC_multipart genera un único questionmult con un bloque
de casillas por parte, cada uno con su propia opción por defecto si last_choice:
AMC_multipart(nombre, etiqueta, enunciado, partes,
last_choice=False, cols=1, profe=False, *,
aux_latex="", last_choice_text="Ninguna de las anteriores")
partes es la lista de pares (enunciado, cuestiones) que devuelven por_partes() y
por_partes_profe(); enunciado es un texto común previo, normalmente "" porque el
de la primera parte ya contiene las hipótesis. Los demás parámetros son los de
AMCblock.
multi = load_problema("ejemplosManual/superheroes.json") # tres partes
with open("preguntas_multi.tex", "w") as f:
for etiqueta, partes in multi.por_partes():
if len(partes) > 1:
f.write(AMC_multipart("Superheroes", etiqueta, "", partes, last_choice=True))
else:
f.write(AMCblock("Superheroes", etiqueta, partes[0][0], partes[0][1], last_choice=True))
5.3. QuizAMCProfe — la vista profe en un fichero
QuizAMCProfe(nombre, directorio, problema, cols=1, *, last_choice=False,
aux_latex="", last_choice_text="Ninguna de las anteriores")
# Devuelve int: número de entradas escritas (una por mundo)
Recorre por_partes_profe() y escribe en directorio/nombre_profe.tex una pregunta AMC
por mundo (con AMC_multipart si tiene varias partes), con todas las cuestiones de
cada hueco marcadas y las rechazadas en \explain{}. problema puede ser un
ProblemaTipo o un diccionario {nombre: ProblemaTipo}. directorio admite / final
o no.
5.4. Integración en el documento AMC
Los ficheros generados se incluyen en el preámbulo del documento AMC con la orden
\input{preguntas.tex}; dentro de \onecopy, \insertgroup[1]{MiCuestionario} inserta
una variante por copia (sin el [1] se insertarían todas) y \shufflegroup las
baraja. El README de la plantilla da el procedimiento completo; consulta la
documentación de AMC para el formato del documento.
6. Exportar a Moodle (vía LaTeX)
Las funciones Quiz* escriben un fichero .tex autónomo que usa el paquete LaTeX
moodle; al compilarlo se obtienen un PDF de revisión y el fichero
nombre-moodle.xml importable en Moodle (Banco de preguntas → Importar → Formato
Moodle XML). Como las de AMC, no leen export: pasa last_choice= e instances=
(los scripts de un banco sí lo leen).
Python (qbank) → nombre.tex → xelatex / pdflatex → nombre.pdf + nombre-moodle.xml → Moodle
El .tex compila con cualquier motor (su cabecera distingue pdflatex de
xelatex o lualatex). Si lo compilas a mano, usa xelatex: el XML sale declarado en
UTF-8. Con pdflatex el paquete moodle lo declara en iso-8859-1 aunque escribe en
UTF-8 los caracteres que codchar no convierte (desde la 0.8.0 codchar cubre todas
las letras del español, mayúsculas y ü incluidas, y los signos ¿ y ¡; quedan, por
ejemplo, las comillas “ ”), y Moodle los mostraría mal (por ejemplo, antigüedad
en lugar de antigüedad);
los proyectos de qbank-init compilan con pdflatex y corrigen la declaración
después (scripts/fix_moodle_xml.py, que make moodle ejecuta).
El paquete moodle se instala con tlmgr install moodle (TeX Live) o
sudo apt install texlive-latex-extra (Debian/Ubuntu). Dos avisos: el paquete declara el
XML en iso-8859-1 pero escribe algunos caracteres en UTF-8, y escapa mal el «<» dentro
de los bloques cloze; los scripts de qbank-init corrigen ambas cosas
(scripts/fix_moodle_xml.py). Si exportas a mano, revisa el XML.
6.1. Funciones
| Función | Tipo de problema | Qué escribe |
|---|---|---|
QuizMoodle |
ProblemaTipo |
Vista alumno: una pregunta cloze por variante, con un bloque por parte. |
QuizMoodleProfe |
ProblemaTipo |
Como QuizMoodle con los mismos parámetros, pero con las puntuaciones visibles. |
QuizVFMoodle |
ProblemaVF |
Verdadero/falso (apéndice). |
QuizMoodle (nombre, directorio, problema, last_choice=False, instances=1, *,
aux_latex="", last_choice_text="Las demás opciones son falsas")
# Devuelve int: número de variantes escritas
QuizMoodleProfe(nombre, directorio, problema, *, aux_latex="",
last_choice=False, instances=1,
last_choice_text="Las demás opciones son falsas")
QuizMoodleProfe solo admite last_choice, instances y last_choice_text por
palabra clave; con los mismos valores que en QuizMoodle escribe las mismas variantes.
Las dos escriben directorio/nombre.tex: para tener ambas vistas, dales nombres
distintos (p. ej. MiCuestionario y MiCuestionarioProfe).
Como el resto de exportadores, ninguna de las dos lee export del problema: pásale
last_choice=True si la pregunta usa la opción por defecto (o, con ejercicio.json,
16 de las 48 variantes saldrían sin ninguna opción correcta).
| Parámetro | Descripción |
|---|---|
nombre |
Nombre del cuestionario (en Moodle, la categoría que agrupa las variantes). |
directorio |
Directorio de salida (con o sin / final). |
problema |
Un ProblemaTipo o un diccionario {nombre: ProblemaTipo} (todas van al mismo cuestionario). |
last_choice |
Si True, añade la opción por defecto a cada bloque. Moodle rechaza preguntas sin ninguna correcta. |
instances |
Instancias con semillas distintas (requiere setup aleatorio). |
aux_latex |
Cabecera LaTeX adicional (p. ej. \usepackage{mis-macros}). |
last_choice_text |
Texto de la opción por defecto. |
Cada bloque cloze recibe un peso igual a su número de opciones menos uno, y Moodle lo puntúa como una opción múltiple con varias respuestas (las correctas marcadas suman, las incorrectas marcadas restan, sin bajar de cero).
6.2. Ejemplo
p = load_problema("ejemplosManual/ejercicio.json")
QuizMoodle("EjemploMoodle", "ejemplosManual/", p, last_choice=True)
cd ejemplosManual xelatex EjemploMoodle.tex
Se generan ejemplosManual/EjemploMoodle-moodle.xml (para importar en Moodle) y
ejemplosManual/EjemploMoodle.pdf (vista previa de las preguntas). Las letras acentuadas,
la ñ, la ü y los signos ¿ y ¡ las convierte codchar (ver la referencia) de forma
automática.
6.3. Preguntas con varias partes
Un ProblemaTipo cuya lista plana abre varias partes se exporta como una pregunta
cloze con un bloque de opciones por parte, sin hacer nada especial. El fichero
ejemplosManual/superheroes.json es un ejercicio de tres partes (cuatro mundos, uno
por héroe, y seis huecos repartidos en tres bloques):
p = load_problema("ejemplosManual/superheroes.json")
etiqueta, partes = next(iter(p.por_partes()))
for enunciado, cuestiones in partes:
print(enunciado.strip() or "(sin texto)")
for texto, correcto, _, exp in cuestiones:
print(f" {'✓' if correcto else '✗'} {texto}")
print(len(list(p.por_partes())), "variantes")
Conteste qué afirmaciones sobre Spider-Man (Peter Parker) son ciertas. Uno de sus superpoderes consiste en
✓ tener sentidos superdesarrollados
✗ volar
En su vestimenta destaca:
✗ la capa
✓ el color azul
En cuanto al origen de sus superpoderes:
✓ son fruto de una mutación
✗ realmente son fruto de entrenamiento y tecnología
256 variantes
import os
os.makedirs("ejemplosManual/exportaciones", exist_ok=True)
QuizMoodle("Superheroes", "ejemplosManual/exportaciones", p, last_choice=True) # Moodle: cloze
with open("ejemplosManual/exportaciones/superheroes_amc.tex", "w") as f: # AMC: un questionmult
for etiqueta, partes in p.por_partes():
f.write(AMC_multipart("Superheroes", etiqueta, "", partes, last_choice=True))
6.4. Subir y actualizar el banco en Moodle desde el terminal
Importar a mano (Banco de preguntas → Importar) basta para una pregunta suelta,
pero al corregir una pregunta ya subida hay que borrar antes sus variantes viejas
de la categoría, y con cientos de variantes por pregunta eso es tedioso y propenso
a errores. El banco qbank-PEconometria tiene dos scripts que reproducen desde el
terminal los formularios de Moodle con la sesión del navegador, sin servicios web
ni permisos de administración: scripts/moodle_sync.py (banco de preguntas) y
scripts/moodle_quiz.py (cuestionarios). No forman parte de la plantilla: están
escritos para el Campus Virtual de la UCM (Moodle 4.3 con auth_saml2; constantes
BASE y COURSEID al principio de moodle_sync.py, o --base y --curso). Para
otro Moodle hay que copiarlos y comprobar que sus páginas son iguales; si no lo son,
los scripts fallan sin tocar nada.
La sesión. Entra en el campus con el navegador, abre las herramientas de
desarrollo (F12; Firefox: Almacenamiento → Cookies; Chrome: Application →
Cookies), copia el valor de la cookie MoodleSession del dominio del campus y
guárdalo, solo el valor, en ~/.config/moodle-ucm/session (permisos 600) o en la
variable MOODLE_SESSION. Equivale a tu contraseña mientras dure la sesión: no la
pegues en ningún fichero versionado. Caduca con la sesión del navegador (en la UCM,
tras una hora de inactividad); si un script dice que no hay sesión, copia otra.
Siempre simulan. Sin --ejecutar los dos scripts solo dicen lo que harían;
ejecútalos primero así y compara con lo que esperas.
PY=~/ruta/a/qbank/.venv/bin/python $PY scripts/moodle_sync.py categorias # categorías y nº de preguntas $PY scripts/moodle_sync.py preguntas L-14-LeerElVIF # ids de una categoría $PY scripts/moodle_sync.py reimportar build/moodle/X.xml # simula $PY scripts/moodle_sync.py reimportar build/moodle/X.xml --ejecutar $PY scripts/moodle_sync.py importar X.xml --ejecutar # solo importar $PY scripts/moodle_sync.py borrar L-14-A L-14-B --ejecutar # vaciar categorías $PY scripts/moodle_quiz.py cuestionarios # cmid, nombre, ranuras, apertura $PY scripts/moodle_quiz.py ranuras 185724 # ranuras de un cuestionario $PY scripts/moodle_quiz.py aleatorias 185725 L-15-A L-15-B --ejecutar
Actualizar preguntas: reimportar. Recibe un XML con las preguntas corregidas
(para unas pocas, fusiona solo sus *-moodle.xml en un fichero aparte; el de todo el
banco también vale). Para cada categoría $course$/top/<nombre> del XML que ya exista
borra sus preguntas visibles y, después, importa el XML una sola vez: Moodle reutiliza
la categoría del mismo nombre (conserva su id) y crea las que falten. Las categorías
no se borran nunca, y es deliberado: las ranuras aleatorias de un cuestionario
filtran por el id de la categoría, y borrar una categoría para volver a importarla
deja esas ranuras rotas. --solo y --excluir limitan las categorías afectadas.
Salvaguardas: aborta si el recuento que muestra Moodle no cuadra con el listado o si
una pregunta aparece en dos categorías; fotografía todo el banco antes y después de
borrar y se detiene antes de importar si cambió algo fuera de lo previsto; al final
compara el recuento de cada categoría con el XML y dice TODO CORRECTO.
Cuestionarios: aleatorias. Añade al final del cuestionario (por su cmid, el
número de mod/quiz/view.php?id=…) una ranura «pregunta aleatoria» por cada categoría
dada, una por página y en el orden dado, y comprueba después que las ranuras nuevas
son las pedidas. Usa el mismo servicio AJAX que el botón del navegador.
Lo que se hace a mano, en el navegador: las fechas de apertura y cierre, la
visibilidad para los estudiantes y quitar ranuras. moodle_quiz.py tiene una orden
apertura que reenvía el formulario de ajustes, pero no se usa: la única vez que se
probó, Moodle devolvió un 404 sin guardar nada y la sesión quedó bloqueada.
Con moderación. El cortafuegos de la UCM llegó a dejar colgadas dos sesiones (toda petición con esa cookie quedaba sin respuesta, y durante un rato no se pudo abrir sesión desde la misma IP) tras ráfagas de peticiones rápidas. Por eso los scripts esperan dos segundos entre peticiones. Haz una tarea, comprueba el resultado y para.
7. La vista de lectura (QuizLectura)
La vista de lectura es un documento LaTeX completo pensado para que el
profesor (o un revisor) lea y opine sobre las preguntas, distinto del
catálogo AMC, que se queda para el examen. Para cada pregunta contiene su
tabla de mundos, una nota con cuántas opciones ve el alumno y cuál es la
opción por defecto, y una serie de ejemplos: el enunciado en un recuadro y,
debajo, las alternativas agrupadas por hueco («Hueco 1, 2, …»; el alumno
recibe una de cada hueco), cada una marcada V (verde) o F (rojo) con su exp
ya interpolada debajo, en gris («la misma retroalimentación que la anterior»
si se repite), y las que la precondición excluye en ese mundo en gris claro
con su motivo. Al final de la pregunta se listan los mundos que un Supuesto
descarta.
QuizLectura(nombre, directorio, problemas, *, aux_latex="", titulo=None,
last_choice=None, last_choice_text="Ninguna de las anteriores",
extras=0, max_semillas=400) # → número de ejemplos
problemases unProblemaTipo, una lista de ellos (un documento por lección es el uso previsto; el nombre de cada uno es sunombre) o un diccionario{nombre: ProblemaTipo}. Escribe solodirectorio/nombre.tex(UTF-8); compilarlo conpdflatexes cosa delMakefile(make lecturaen la plantilla).aux_latexes el preámbulo del proyecto (macros,babel), como en AMC y Moodle; el documento carga por su cuentainputenc,fontenc,amsmath,amssymb,geometry,xcolor,enumitemygraphicx.last_choice=Noneleeexport.last_choicede cada pregunta (una excepción a la regla de que los exportadores no leenexport, porque en un mismo documento las preguntas difieren); solo afecta a la nota inicial.
Qué ejemplos salen:
- Sin
setup: uno por mundo (cada combinación deSupuestoy textos alternativos). - Con
setupy sin más indicación: uno por mundo con laseedde la pregunta, másextrassorteos por mundo cuyo enunciado (sin cifras) sea distinto de los ya vistos. - Con la clave
lecturaen el JSON (o el argumentolecturadeProblemaTipo),"lectura": {"cubrir": ["ctx", "rech"]}: en cada mundo, un ejemplo por cada combinación de valores de esas variables delsetup, buscada recorriendo hastamax_semillassemillas. Las combinaciones del producto de los valores vistos que no aparezcan se listan al final como no encontradas (unaprecondpuede hacerlas imposibles). Los valores deben ser discretos;lintavisa si son reales o si hay demasiadas combinaciones, y da ERROR si una variable no existe en elsetup. Si la variable que interesa no existe como tal (por ejemplo, «rechaza» se deduce de unp-valor), basta definirla en elsetup:rech = p < 0.05.
Los datos salen de ProblemaTipo.por_partes_lectura(seed=None,
verbose=False, descartes=False), que es como por_partes_profe() pero
conserva los huecos y el espacio de nombres del setup: produce registros
Lectura(etiqueta, mundo, ns, partes, descarte), donde mundo es la tupla
de índices elegidos en los slots de enunciado, cada parte es
ParteLectura(enunciado, huecos) y cada alternativa
Alternativa(texto, valor, exp, motivo) (valor es None, con motivo,
si la precondición la excluye). Con descartes=True emite también los
mundos descartados por un Supuesto (partes=None, descarte con el
motivo).
8. Comprobación automática de preguntas (lint)
Antes de exportar conviene comprobar que la pregunta está bien construida. lint
automatiza lo que de otro modo habría que mirar a ojo recorriendo todas las variantes.
Acepta un ProblemaTipo, un dict o la ruta a un fichero, no modifica nada ni compila
LaTeX, y devuelve una lista de Aviso(nivel, codigo, mensaje, donde) con nivel
ERROR, AVISO o INFO, errores primero:
for aviso in lint("ejemplosManual/ej_c_precond.json"):
print(aviso)
INFO [constante] falsa en los 2 mundos en que aparece (semantica literal) @ comp 4[2]: $\det(A)$ puede ser cualquier número real
INFO [constante] falsa en los 2 mundos en que aparece (semantica literal) @ comp 5[3]: $A^{-1} = A^\top$ necesariamente
INFO [constante] falsa en los 2 mundos en que aparece (semantica literal) @ comp 6[2]: el sistema $Ax=b$ tiene siempre infinitas soluciones
Cada aviso lleva su nivel, su código entre corchetes, el mensaje y, tras @, dónde
está: comp 4[2] es el componente 4 de la lista (contando desde 0), elemento 2.
Aquí son tres INFO: tres distractores siempre falsos, puestos a propósito.
Un ERROR significa que la pregunta está rota; un AVISO hay que leerlo y decidir;
un INFO es información. Las reglas, con el código de cada aviso:
mezcla(ERROR)- en alguna variante y parte todas las opciones son correctas o
todas incorrectas, contando la opción por defecto si
export.last_choiceestá activo (misma regla que los exportadores). pocas-opciones(AVISO)- una parte muestra al alumno menos de tres opciones: el
error típico de poner las opciones en una única lista de
Cuestion(que sortea una).opciones(AVISO): una parte sin ninguna cuestión (típicamente, un texto suelto tras la últimaCuestion; esa parte no da ademásmezcla), o con un número de opciones que varía entre variantes. variantes- ERROR si la pregunta no genera ninguna variante válida. AVISO con menos
de 16 variantes sin
setup, o menos de 60 consetup(contandoexport.instances). INFO con más de 300: la vista alumno de AMC en un soloexamcopypuede agotar la memoria depdflatex. ejecucion(ERROR)- el
setupo el recorrido de las variantes lanzan una excepción. constante(AVISO)- una
Cuestionverdadera (o falsa) en todos los mundos en que puede aparecer, candidata a distractor memorizable. Es INFO si la semántica es el literalTrueoFalse(distractor deliberado) o unalambdaconsetup(la tabla de mundos usa un solo sorteo; compruébalo con la vista profe).solape(AVISO): dosCuestioncon el mismo texto en el mismo hueco pueden aparecer en un mismo mundo. negacion- una
semanticaoprecondusa-v('X')pero ningúnSupuestopermite deducirlo (hace falta ununoDeque incluyav('X'), a cualquier profundidad, o unSupuestoque afirme o implique-v('X')). ERROR siv('X')es una alternativa de una lista; AVISO si niega unSupuestosuelto (siempre presente): nunca será cierta. interp(ERROR)- queda algún
@{var}sin sustituir en el texto renderizado (el escape@@{x}, que se imprime como «@{x}», no cuenta). huerfana(AVISO)- una etiqueta
v('X')usada en unaCuestionno aparece en ningúnSupuesto(INFO si algúnSupuestoes una lambda, porque entonces no se puede saber).sin-uso(INFO): una etiqueta declarada que ningunaCuestionusa. carga,tipo,nombre,estructura(ERROR)- el JSON no se puede leer o cargar
(
carga); no es unProblemaTipo(tipo); elnombreno coincide con el fichero o lleva:o espacios, que los scripts del banco no admiten (nombre); alguna lista está vacía, mezcla tipos o va anidada, oexportoseedestán mal tipados (estructura;"seed": truetampoco vale).lista-1,seed,instances(AVISO): listas de un solo elemento,seedincoherente con el uso de aleatorios,instances > 1sinsetup. texto- un
%sin barra es ERROR (en LaTeX empieza un comentario y el PDF sale vacío o roto sin quepdflatexpare). Son AVISO:\%dentro de$…$(Moodle lo pierde),°,«»y comillas rectas (no existen en la codificación del paquetemoodleo salen como ”),\(…\)o\[…\]en vez de$…$/$$…$$, citas a números de lección,@{sin cerrar, dobles espacios fuera de las fórmulas, comillas simples, textos que parecen códigolambda ns:(desde JSON no se evalúan). Las dos reglas del%se aplican también al texto interpolado con unos sorteos delsetup(el mensaje lo indica con «en el texto interpolado»). setup(INFO)- variantes por instancia e
instancesnecesarias para llegar a 60. patron(AVISO; solo conpatrones=Trueo--patrones)- patrones memorizables:
lo que un alumno podría acertar sin leer el enunciado tras ver varias variantes.
Muestrea las variantes (40 sorteos del
setup, o los que se pidan) y avisa de unaCuestionde verdad variable pero «casi constante» (el mismo valor en ≥ 85 % de sus apariciones; las constantes del todo las daconstante), de una lambda consetupconstante en toda la muestra (dondeconstantesolo pudo dar INFO), de una cifra que dentro de un «tipo de frase» (el texto sin cifras) separa por completo las verdaderas de las falsas (una elasticidad verdadera siempre menor que 1 y su gemela inversa siempre mayor), y de un rasgo superficial de la opción —una palabra, una cifra concreta, repetir una cifra del enunciado, tener la cifra mayor o menor del hueco, ser más larga que la mediana— que, con él o sin él, predice la verdad en el hueco (≥ 85 % de acierto, ≥ 15 puntos sobre la tasa base, soporte a ambos lados y p-valor corregido). Los rasgos salen del propio banco (no hay lista de palabras ni dependencia del idioma), y no se miran relaciones aritméticas con los datos del enunciado: en una pregunta de cálculo la respuesta correcta debe ser función de ellos. Es más lenta que el resto dellint, por eso no corre por defecto; sus avisos son indicios que hay que leer: a veces el remedio es una gemela más (que la cifra grande sea a veces la verdadera), a veces no hay nada que corregir.
tabla_mundos(problema) devuelve una tabla de texto con el valor de cada Cuestion
(V, F, o - si la rechaza su precond) en cada combinación de Supuesto, para
localizar distractores constantes y auditar la lógica. Con setup usa un solo sorteo
(sembrado con seed), así que los mundos filtrados por precondiciones numéricas pueden
no aparecer todos.
Desde la línea de órdenes (código de salida 1 si hay algún ERROR; --quiet omite los
INFO y los ficheros sin avisos):
python -m qbank.lint preguntas/*.json --quiet qbank-lint preguntas/L-01-Nombre.json --tabla # equivalente, con la tabla de mundos qbank-lint preguntas/*.json --quiet --patrones # además, patrones memorizables (--patrones 100: más sorteos)
En un proyecto de qbank-init: make lint, make tabla PREGUNTA=L-01-Nombre,
make patrones (SEMILLAS=N para cambiar los sorteos), y make amc / make moodle
pasan lint antes de exportar.
9. Referencia rápida de la API
9.1. Clases
Supuesto(enunciado, semantica, precond=True)
Cuestion(enunciado, semantica, precond=True, exp="")
semantica y precond: fórmula calcprop, True/False, lambda ns: …, o la cadena de cualquiera de ellos
ProblemaTipo(componentes, setup=None, export=None, seed=None, nombre=None)
→ .por_partes(instances=1, base_seed=None, verbose=False) vista alumno, por partes
→ .por_partes_profe(verbose=True) vista profe: todas las cuestiones
→ itera (etiqueta, enunciado, cuestiones) solo ejercicios de una parte
ProblemaVF(enunciado, cuestiones, NumPreguntas) verdadero/falso (apéndice)
ProblemaTipoEditor(source=None) editor visual en Jupyter (apéndice)
qbank.__version__ versión instalada, p. ej. "0.6.0"
9.2. Funciones JSON
problema_from_dict(d) → ProblemaTipo o ProblemaVF problema_to_dict(problema) → dict (conserva las claves propias del JSON) load_problema(ruta_o_dict) → ProblemaTipo o ProblemaVF save_problema(problema, ruta) load_banco(ruta) → lista de problemas save_banco(problemas, ruta) # acepta lista o dict problema_to_python(problema, varname="ejercicio") → str (ProblemaTipo) save_problema_py(problema, ruta, varname="ejercicio")
9.3. Funciones AMC
AMCblock (nombre, etiqueta, enunciado, cuestiones,
last_choice=False, cols=1, profe=False, *,
aux_latex="", last_choice_text="Ninguna de las anteriores") → str
AMC_multipart(nombre, etiqueta, enunciado, partes,
last_choice=False, cols=1, profe=False, *,
aux_latex="", last_choice_text="Ninguna de las anteriores") → str
QuizAMCProfe (nombre, directorio, problema, cols=1, *, last_choice=False,
aux_latex="", last_choice_text="Ninguna de las anteriores") → int (mundos)
AMC_VF (nombre, etiqueta, enunciado, cuestiones, opc=None) → str (ProblemaVF)
9.4. Funciones Moodle
QuizMoodle (nombre, directorio, problema, last_choice=False, instances=1, *,
aux_latex="", last_choice_text="Las demás opciones son falsas") → int (variantes)
QuizMoodleProfe (nombre, directorio, problema, *, aux_latex="",
last_choice=False, instances=1,
last_choice_text="Las demás opciones son falsas") → int
QuizVFMoodle (nombre, directorio, GenVar, num, opc=None) (ProblemaVF)
codchar(s) vocales acentuadas minúsculas y ñ → secuencias LaTeX (lo aplican las funciones Moodle)
9.5. Vista de lectura
QuizLectura(nombre, directorio, problemas, *, aux_latex="", titulo=None,
last_choice=None, last_choice_text="Ninguna de las anteriores",
extras=0, max_semillas=400) → int (ejemplos)
ProblemaTipo.por_partes_lectura(seed=None, verbose=False, descartes=False)
# → Lectura(etiqueta, mundo, ns, partes, descarte); ParteLectura(enunciado, huecos);
# Alternativa(texto, valor, exp, motivo)
9.6. Comprobación automática
lint(problema_o_ruta, instances=None, seed=None, patrones=None) → list[Aviso]
# Aviso(nivel, codigo, mensaje, donde); nivel ∈ {ERROR, AVISO, INFO}; errores primero
# patrones=True (o nº de sorteos): añade la regla 11, patrones memorizables (más lenta)
tabla_mundos(problema_o_ruta) → str # tabla Cuestion × mundo
python -m qbank.lint FICHEROS.json [--instances N] [--seed S] [--tabla] [--quiet] [--patrones [N]]
# también `qbank-lint`; código de salida 1 si hay algún ERROR
9.7. Proyecto de banco
qbank-init DIR [--forzar] copia la plantilla (preguntas/, scripts/, Makefile, config.py,
nueva_pregunta.py, README.org, prompts/, CLAUDE.md) en DIR; equivale a
python -m qbank._plantilla DIR
9.8. Operadores de calcprop (disponibles tras from qbank import *)
v("X") variable proposicional X
-v("X") negación de X
v("X") & v("Y") conjunción (X ∧ Y)
v("X") | v("Y") disyunción (X ∨ Y)
v("X") >> v("Y") implicación (X → Y)
v("X") ** v("Y") bicondicional (X ↔ Y)
alguno(P, Q, R) al menos una de la lista es verdadera
unoDe(P, Q, R) exactamente una de la lista es verdadera
True tautología (siempre verdadero)
False contradicción (siempre falso)
test(formula, lista_de_formulas) → bool
Devuelve True si formula es consecuencia lógica de la lista de hipótesis.
10. Preguntas frecuentes
10.1. ¿Por qué algunas variantes no se generan?
Si un Supuesto o una Cuestion tiene una precondición que no se satisface en una
combinación determinada, esa variante completa se descarta. Es el comportamiento
esperado: evita combinaciones de hipótesis incoherentes. En la vista profe, las
cuestiones rechazadas se ven; un Supuesto rechazado hace desaparecer la variante
también allí.
10.2. ¿Cómo incluyo texto LaTeX en los enunciados?
Los campos enunciado y exp son cadenas Python que se insertan tal cual en el .tex.
Usa cadenas crudas (r"...") para no doblar las barras invertidas:
Cuestion(r"$\mathbf{X}^\top\mathbf{X}$ es invertible", -v("Mcoli"))
En JSON cada barra se dobla: "$\\mathbf{X}^\\top\\mathbf{X}$".
10.3. ¿Puedo escribir un porcentaje, grados, comillas…?
El símbolo % es, en LaTeX, el inicio de un comentario: escribe siempre \% (y fuera
de los dólares: al $5$\,\%). Nada de ° (usa ^\circ) ni de comillas angulares «» o
rectas "…" (usa ``así''): el paquete moodle no las admite o las deforma. Acentos,
ñ, ¿ y ¡ sí se pueden teclear. lint avisa de todo esto; la Guía lo explica.
10.4. ¿Cómo escribo display math?
Con $$...$$, nunca \[...\] ni \(...\) directamente: el paquete moodle necesita
los dólares, y los exportadores AMC convierten $$...$$ a \[...\] automáticamente
(automultiplechoice a veces produce errores «Missing $» con $$). Separa dos
expresiones en línea contiguas con un espacio ($A$ $B$, no $A$$B$).
10.5. ¿Puedo incluir una figura en el enunciado?
Sí, con \includegraphics en el texto (el enunciado es LaTeX), pero cada destino la
quiere de una forma. En AMC vale un PDF a tamaño natural; los documentos de la
plantilla ya cargan graphicx:
"Observe la figura. \\begin{center}\\includegraphics{fig.pdf}\\end{center} Entonces:"
El paquete moodle incrusta la imagen en el XML (en base64) y solo
sabe de mapas de bits: hace falta un PNG del mismo nombre y compilar con
pdflatex -shell-escape, porque llama a base64. Un patrón que funciona: generar
fig.pdf y fig.png (p. ej. con pdftoppm -png -r 200), referenciar el PDF en el JSON
y, en el script de Moodle, sustituir .pdf por .png en el .tex (con width igual a
la anchura natural del PDF, para que Moodle no lo agrande); el banco de
Probabilidad y Estadística del autor lo hace así en su scripts/gen_moodle.py. Si la ruta de la figura
va en el setup como variable e interpolada con @{fig}, la misma pregunta sirve para
los dos destinos. Ten en cuenta el tamaño: una figura en cada una de 150 variantes
multiplica el XML.
10.6. ¿Para qué sirve codchar?
codchar(s) convierte las letras del español con tilde o diéresis, mayúsculas y
minúsculas, y la ñ a órdenes de acento de LaTeX (á → \'{a}, ü → \"{u}), y los
signos ¿ y ¡ a \textquestiondown y \textexclamdown. Las funciones Moodle lo aplican
automáticamente; no hace falta llamarlo a mano. El paquete moodle convierte esas
órdenes en entidades HTML (á, ü, ¿), que se leen bien se declare
el XML como se declare. Un carácter que codchar no cubra (las comillas “ ”, por
ejemplo) pasa tal cual: con xelatex sale bien; con pdflatex llega al XML en UTF-8
con la declaración iso-8859-1 y lo corrige scripts/fix_moodle_xml.py de la plantilla
(ver la sección 6). Hasta la 0.7.0 codchar solo cubría
las minúsculas acentuadas y la ñ, y un banco con una copia antigua de ese script
mostraba en Moodle antigüedad o ¿Qué. Para AMC no suele hacer falta si el documento
principal usa inputenc con UTF-8.
10.7. ¿Cómo exporto varias preguntas a un mismo cuestionario Moodle?
Pasa un diccionario a QuizMoodle: todas van al cuestionario MiExamen, y cada clave
da nombre a sus variantes (Seccion-A-1, Seccion-A-2, …):
QuizMoodle("MiExamen", "salida/", {"Seccion-A": p_a, "Seccion-B": p_b}, last_choice=True)
Un banco de qbank-init guarda cada pregunta en su propio fichero, y el script
scripts/merge_moodle_xml.py fusiona sus XML en _banco.xml.
11. Apéndice: API avanzada y compatibilidad
11.1. ProblemaVF — preguntas de verdadero/falso
ProblemaVF sirve para un formato distinto: un banco de afirmaciones del que se extrae
aleatoriamente un subconjunto en cada variante. No usa cálculo proposicional; la
corrección de cada ítem se indica con True o False. No lo revisa lint ni lo
exportan los scripts de qbank-init: para un banco nuevo, una pregunta de tres huecos
de ProblemaTipo cubre el mismo uso con todas las herramientas.
ProblemaVF(enunciado, cuestiones, NumPreguntas)
| Parámetro | Descripción |
|---|---|
enunciado |
Texto del enunciado común a todas las variantes. |
cuestiones |
Lista de tuplas (texto, correcto). |
NumPreguntas |
Cuántas afirmaciones se eligen aleatoriamente por variante. |
enunciado = "Indique qué afirmaciones son verdaderas:"
banco = [
("Todo alumno que va a clase aprueba", False),
("Todo alumno que sabe aprueba", True),
("Si suspenden todos es frustrante", True),
("Si aprueban todos eres buen profesor", False),
]
import random
random.seed(42)
GenVar = iter(ProblemaVF(enunciado, banco, 3)) # 3 ítems por variante
for _ in range(2):
etiqueta, enunc, cuestiones = next(GenVar)
print(f"Variante {etiqueta}: {enunc}")
for texto, correcto in cuestiones:
print(f" {'✓' if correcto else '✗'} {texto}")
Variante 1: Indique qué afirmaciones son verdaderas: ✗ Todo alumno que va a clase aprueba ✗ Si aprueban todos eres buen profesor ✓ Todo alumno que sabe aprueba Variante 2: Indique qué afirmaciones son verdaderas: ✓ Todo alumno que sabe aprueba ✗ Todo alumno que va a clase aprueba ✓ Si suspenden todos es frustrante
Se exporta con AMC_VF(nombre, etiqueta, enunciado, cuestiones) (un bloque por
variante) y QuizVFMoodle(nombre, directorio, GenVar, num) (num variantes). Se guarda
y carga en JSON sin restricciones.
11.2. Clases deprecadas
ProblemaTipoProfeemite
DeprecationWarning; usaProblemaTipo(...).por_partes_profe(). Firma:ProblemaTipoProfe(lista, setup=None)
ProblemaMultiParteySubPreguntaProblemaMultiParteemiteDeprecationWarningy devuelve unProblemaTipoequivalente (lista plana);SubPreguntaes su contenedor. Para código nuevo, define directamente unProblemaTipoy recórrelo conpor_partes(). Firmas:ProblemaMultiParte(componentes, subpreguntas, setup=None) SubPregunta(intro, cuestiones)
11.3. Editor visual en Jupyter (ProblemaTipoEditor)
ProblemaTipoEditor es un formulario interactivo para diseñar un ProblemaTipo desde
un cuaderno de Jupyter, sin escribir la lista de listas a mano. Requiere ipywidgets:
pip install "calcprop-qbank[jupyter]"
(la orden con git+https://… de la instalación rápida ya incluye el extra
[jupyter]).
En JupyterLab ≥ 4 es necesario que la extensión @jupyter-widgets/jupyterlab-manager
esté habilitada: compruébalo con jupyter labextension list. Si el widget se muestra
como texto
(VBox(children=...), ejecuta una sola vez desde el terminal:
mkdir -p ~/.local/share/jupyter/labextensions/@jupyter-widgets ln -sfn $(python -c "import jupyterlab_widgets, os; \ print(os.path.dirname(jupyterlab_widgets.__file__))")/labextension \ ~/.local/share/jupyter/labextensions/@jupyter-widgets/jupyterlab-manager
y reinicia JupyterLab (en Jupyter Notebook clásico no hace falta).
from qbank import ProblemaTipoEditor
editor = ProblemaTipoEditor() # editor vacío
editor = ProblemaTipoEditor('mi_problema.json') # cargar desde fichero
p = editor.to_problema() # ProblemaTipo listo para iterar
d = editor.to_dict() # dict JSON equivalente
save_problema(editor.to_problema(), "mi_problema.json") # o el botón 💾 Guardar
El editor no tiene un método save: para guardar, usa el botón 💾 Guardar o pasa
editor.to_problema() a save_problema, como en la última línea del bloque.
El formulario tiene una cabecera (Nombre:, Setup: con el campo semilla:, y
Export: con la casilla last_choice y el campo cols:), la lista de huecos y una
barra de botones. Cada hueco (Hueco 1, Hueco 2, etc.) tiene un tipo, texto,
supuestos o cuestiones, y sus alternativas (+ alt): filas con enunciado,
semántica, precondición y, en las cuestiones, explicación. Los botones de cada hueco lo
suben o bajan (▲ / ▼), insertan otro hueco debajo (+) o lo borran (✕); el botón
+ hueco añade uno al final. La barra inferior tiene ▶ Preview (genera hasta N
variantes, con la casilla vista profe), { } JSON y </> .py (muestran el JSON o el
guion Python), 💾 Guardar y 📂 Cargar (en el fichero indicado) y ⬇ .json /
⬇ .py (descargas al navegador). Al guardar conserva las claves de export que el
formulario no edita (como instances) y las claves propias del JSON.
Demo.ipynb (sección 8) lo muestra en uso.