quiz_cloze
Make cloze (gap-fill) questions from a text: blank the most frequent or longest content words, a given list of words or phrases, or every n-th word, reproducibly with an optional seed; returns the gapped text with numbered blanks, the answer key and the same text in Anki cloze syntax ({{c1::...}}), one card per gap or all on one card. English or Spanish stopwords.
Install
/tools install quiz_cloze /tools install quiz_cloze@1.0.0
In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · quiz_cloze 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool quiz_cloze; each run is approved as a local_exec.
Arguments
One JSON object on stdin, validated against the schema before the program starts.
| name | type | required | default | meaning |
|---|---|---|---|---|
text | string | yes | length ≥ 1; length ≤ 200000 | |
mode | string | no | "auto" | one of "auto", "terms", "nth" |
count | integer | no | 5 | auto: distinct words to blank. ≥ 1; ≤ 200 |
rank_by | string | no | "frequency" | one of "frequency", "length" |
min_length | integer | no | 4 | ≥ 1; ≤ 30 |
terms | array of string | no | terms: words or phrases to blank | |
every_n | integer | no | nth: blank every n-th word. ≥ 2; ≤ 100 | |
offset | integer | no | nth: first blank (1-based), default n. ≥ 1; ≤ 100 | |
content_only | boolean | no | false | nth: count content words only |
all_occurrences | boolean | no | false | |
language | string | no | "en" | one of "en", "es" |
seed | integer | no | Shuffles ties (auto) and picks the offset (nth) reproducibly | |
blank | string | no | "_____" | length ≥ 1; length ≤ 40 |
anki_single_card | boolean | no | false |
Input schema (JSON)
{
"type": "object",
"required": [
"text"
],
"additionalProperties": false,
"properties": {
"text": {
"type": "string",
"minLength": 1,
"maxLength": 200000
},
"mode": {
"type": "string",
"enum": [
"auto",
"terms",
"nth"
],
"default": "auto"
},
"count": {
"type": "integer",
"minimum": 1,
"maximum": 200,
"default": 5,
"description": "auto: distinct words to blank"
},
"rank_by": {
"type": "string",
"enum": [
"frequency",
"length"
],
"default": "frequency"
},
"min_length": {
"type": "integer",
"minimum": 1,
"maximum": 30,
"default": 4
},
"terms": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"description": "terms: words or phrases to blank"
},
"every_n": {
"type": "integer",
"minimum": 2,
"maximum": 100,
"description": "nth: blank every n-th word"
},
"offset": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "nth: first blank (1-based), default n"
},
"content_only": {
"type": "boolean",
"default": false,
"description": "nth: count content words only"
},
"all_occurrences": {
"type": "boolean",
"default": false
},
"language": {
"type": "string",
"enum": [
"en",
"es"
],
"default": "en"
},
"seed": {
"type": "integer",
"description": "Shuffles ties (auto) and picks the offset (nth) reproducibly"
},
"blank": {
"type": "string",
"minLength": 1,
"maxLength": 40,
"default": "_____"
},
"anki_single_card": {
"type": "boolean",
"default": false
}
}
}
Example
Input
{
"text": "The mitochondria is the powerhouse of the cell. The cell needs energy, and the mitochondria make it.",
"count": 2
}
Output (stdout)
{
"cloze_text": "The _____ (1) is the powerhouse of the _____ (2). The cell needs energy, and the mitochondria make it.",
"answers": [
"mitochondria",
"cell"
],
"anki": "The {{c1::mitochondria}} is the powerhouse of the {{c2::cell}}. The cell needs energy, and the mitochondria make it."
}
Files
The command the install card registers is python3 quiz_cloze.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.
quiz_cloze.py · sha256 d943b7ea04a249e7d060ff0781bd729033ce7e42fd870e65a6987fe08ec8668d
#!/usr/bin/env python3
"""Cloze (gap-fill) questions from a text, with an answer key and Anki cloze syntax.
Input (JSON on stdin):
text the passage (required)
mode "auto" | "terms" | "nth" default auto
count auto: how many distinct words to blank, default 5
rank_by auto: "frequency" (then length) | "length" (then frequency), default frequency
min_length auto: shortest word considered, default 4
terms terms: the words or phrases to blank (case-insensitive, whole words)
every_n nth: blank every n-th word
offset nth: position of the first blank (1-based), default n (or seeded when seed is given)
content_only nth: count only content words (skip stopwords), default false
all_occurrences auto, terms: blank every occurrence, not just the first, default false
language "en" | "es" stopword list, default en
seed optional integer: shuffles ties in auto and picks the nth offset, reproducibly
blank gap text, default "_____"
anki_single_card true: every gap is c1 (one card); default false (c1, c2, ... one card each)
"""
import bisect
import json
import random
import re
import sys
WORD = re.compile(r"[^\W\d_]+(?:['’-][^\W\d_]+)*")
STOP = {
"en": set("""a about above after again against all also am an and any are as at be because been before being
below between both but by can could did do does doing down during each few for from further had has have having he
her here hers herself him himself his how i if in into is it its itself just me more most my myself no nor not now
of off on once only or other our ours ourselves out over own same she should so some such than that the their
theirs them themselves then there these they this those through to too under until up very was we were what when
where which while who whom why will with would you your yours yourself yourselves upon within without also
however therefore thus there's it's they're""".split()),
"es": set("""a al algo algunas algunos ante antes como con contra cual cuando de del desde donde durante e el
ella ellas ellos en entre era eran es esa esas ese eso esos esta estaba estado estan estar este esto estos fue
fueron ha han hasta hay la las le les lo los mas me mi mis mucho muy nada ni no nos nosotros o os otra otro para
pero poco por porque que quien se sea segun ser si sin sino sobre son su sus tambien tanto te tiene tienen todo
todos tu tus un una unas uno unos y ya más está están también según así cuál él sí sólo solo""".split()),
}
def fail(message):
print(json.dumps({"error": message}))
sys.exit(2)
def integer(request, field, default, low, high):
value = request.get(field, default)
if isinstance(value, bool) or not isinstance(value, int) or not low <= value <= high:
fail(f"{field} must be an integer from {low} to {high}")
return value
def boolean(request, field, default=False):
value = request.get(field, default)
if not isinstance(value, bool):
fail(f"{field} must be true or false")
return value
def main():
try:
request = json.load(sys.stdin)
except ValueError as error:
fail(f"stdin is not JSON: {error}")
if not isinstance(request, dict):
fail("input must be a JSON object")
extra = sorted(set(request) - {"text", "mode", "count", "rank_by", "min_length", "terms", "every_n", "offset",
"content_only", "all_occurrences", "language", "seed", "blank", "anki_single_card"})
if extra:
fail(f"unknown field(s): {', '.join(extra)}")
text = request.get("text")
if not isinstance(text, str) or not text.strip():
fail("text must be a non-empty string")
if len(text) > 200_000:
fail("text is longer than 200,000 characters")
mode = request.get("mode", "auto")
if mode not in ("auto", "terms", "nth"):
fail("mode must be auto, terms or nth")
language = request.get("language", "en")
if not isinstance(language, str) or language not in STOP:
fail("language must be en or es")
stop = STOP[language]
seed = request.get("seed")
if seed is not None and (isinstance(seed, bool) or not isinstance(seed, int)):
fail("seed must be an integer")
rng = random.Random(seed) if seed is not None else None
blank = request.get("blank", "_____")
if not isinstance(blank, str) or not 1 <= len(blank) <= 40:
fail("blank must be a string of 1 to 40 characters")
single = boolean(request, "anki_single_card")
every = boolean(request, "all_occurrences")
words = [(m.start(), m.end(), m.group()) for m in WORD.finditer(text)]
if not words:
fail("text has no words")
spans, not_found, chosen_words = [], [], []
if mode == "auto":
count = integer(request, "count", 5, 1, 200)
min_length = integer(request, "min_length", 4, 1, 30)
rank_by = request.get("rank_by", "frequency")
if rank_by not in ("frequency", "length"):
fail("rank_by must be frequency or length")
freq = {}
for _, _, w in words:
key = w.casefold()
if len(w) >= min_length and key not in stop and key.replace("’", "'") not in stop:
freq[key] = freq.get(key, 0) + 1
score = (lambda k: (freq[k], len(k))) if rank_by == "frequency" else (lambda k: (len(k), freq[k]))
groups = {}
for key in sorted(freq):
groups.setdefault(score(key), []).append(key)
ranked = []
for s in sorted(groups, reverse=True):
group = groups[s]
if rng:
rng.shuffle(group)
ranked += group
chosen_words = ranked[:count]
targets = set(chosen_words)
seen = set()
for start, end, w in words:
key = w.casefold()
if key in targets and (every or key not in seen):
spans.append((start, end))
seen.add(key)
elif mode == "terms":
terms = request.get("terms")
if not isinstance(terms, list) or not terms or not all(isinstance(t, str) and t.strip() for t in terms):
fail("terms must be a non-empty list of words or phrases")
taken = []
for term in terms:
parts = [re.escape(p) for p in term.split()]
pattern = re.compile(r"(?<![\w’'-])" + r"\s+".join(parts) + r"(?![\w’'-])", re.IGNORECASE)
hits = [m.span() for m in pattern.finditer(text)
if not any(m.start() < e and s < m.end() for s, e in taken)]
if not hits:
not_found.append(term)
continue
for hit in (hits if every else hits[:1]):
taken.append(hit)
spans.append(hit)
chosen_words.append(term)
else:
n = request.get("every_n")
if isinstance(n, bool) or not isinstance(n, int) or not 2 <= n <= 100:
fail("every_n must be an integer from 2 to 100")
content_only = boolean(request, "content_only")
pool = [w for w in words if not content_only or w[2].casefold() not in stop]
if request.get("offset") is not None:
offset = integer(request, "offset", None, 1, n)
else:
offset = rng.randint(1, n) if rng else n
spans = [(s, e) for s, e, _ in pool[offset - 1::n]]
chosen_words = [text[s:e] for s, e in spans]
request["offset"] = offset
spans.sort()
if not spans:
fail("nothing to blank: no word matched the selection")
starts = [w[0] for w in words]
out_text, anki, key = [], [], []
last = 0
for i, (s, e) in enumerate(spans, 1):
answer = text[s:e]
out_text.append(text[last:s] + f"{blank} ({i})")
c = 1 if single else i
anki.append(text[last:s] + "{{c%d::%s}}" % (c, answer.replace("}}", "} }").replace("::", ": :")))
word_index = bisect.bisect_left(starts, s) + 1
key.append({"n": i, "answer": answer, "char_offset": s, "word_number": word_index})
last = e
out_text.append(text[last:])
anki.append(text[last:])
result = {"mode": mode, "cloze_text": "".join(out_text), "answer_key": key,
"answers": [k["answer"] for k in key], "anki": "".join(anki), "blanks": len(key), "words": len(words)}
if mode == "auto":
result["selected_words"] = chosen_words
if mode == "terms":
result["not_found"] = not_found
if mode == "nth":
result["offset"] = request["offset"]
if seed is not None:
result["seed"] = seed
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
try:
main()
except SystemExit:
raise
except Exception as error: # last resort: a usage error, never a traceback
fail(f"invalid input ({type(error).__name__})")
Where it is served
The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/quiz_cloze/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/quiz_cloze/1.0.0/quiz_cloze.py