AXLR / Catalog / education

bloom_classify

Classify learning objectives by the level of the revised Bloom taxonomy (Remember, Understand, Apply, Analyze, Evaluate, Create) from their main verb, in English or Spanish, with a bundled verb table built on the 19 cognitive processes of Anderson & Krathwohl (2001). Recognises inflected forms, names the taxonomy process, flags verbs listed at more than one level and non-measurable verbs such as understand or know, and summarises the levels of a list.

Install

/tools install bloom_classify
/tools install bloom_classify@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 · bloom_classify 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 bloom_classify; each run is approved as a local_exec.

Arguments

One JSON object on stdin, validated against the schema before the program starts.

nametyperequireddefaultmeaning
objectivesarray of stringyesitems ≥ 1; items ≤ 200
languagestringno"auto"one of "en", "es", "auto"
Input schema (JSON)
{
  "type": "object",
  "required": [
    "objectives"
  ],
  "additionalProperties": false,
  "properties": {
    "objectives": {
      "type": "array",
      "minItems": 1,
      "maxItems": 200,
      "items": {
        "type": "string",
        "minLength": 1,
        "maxLength": 2000
      }
    },
    "language": {
      "type": "string",
      "enum": [
        "en",
        "es",
        "auto"
      ],
      "default": "auto"
    }
  }
}

Example

Input

{
  "objectives": [
    "Students will be able to compare the French and American revolutions"
  ]
}

Output (stdout)

{
  "results": [
    {
      "verb": "compare",
      "level": "understand",
      "level_name": "Understand",
      "process": "comparing",
      "ambiguous": true
    }
  ]
}

Files

The command the install card registers is python3 bloom_classify.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.

bloom_classify.py · sha256 f6a0a151505ba6d3e26676f6d69f27611322dd4a635d06cc780c8995aabe015c
#!/usr/bin/env python3
"""Classify learning objectives by the level of the revised Bloom taxonomy (Anderson &
Krathwohl 2001, cognitive process dimension) from the main verb, in English or Spanish.

Input (JSON on stdin):
  objectives   ["Students will be able to compare ...", ...]  (1-200 strings)
  language     "en" | "es" | "auto"   default auto (per objective)

The verb table below is bundled in the program. Entries written verb:process are the 19
cognitive processes of the revised taxonomy and their alternative names (Anderson & Krathwohl
2001, Table 5.1; Krathwohl 2002, Theory Into Practice 41(4) 212-218); the other verbs are common
usage, compiled for this tool (MIT). A verb listed under more than one level is "ambiguous";
its first level is the one of the taxonomy's own process when it has one.
"""
import json
import re
import sys
import unicodedata

LEVELS = ["remember", "understand", "apply", "analyze", "evaluate", "create"]
LEVEL_NAMES = {"en": ["Remember", "Understand", "Apply", "Analyze", "Evaluate", "Create"],
               "es": ["Recordar", "Comprender", "Aplicar", "Analizar", "Evaluar", "Crear"]}
EN = {
    "remember": "recognize:recognizing identify:recognizing recall:recalling retrieve:recalling list name define state "
                "memorize repeat label reproduce locate quote describe",
    "understand": "interpret:interpreting clarify:interpreting paraphrase:interpreting represent:interpreting "
                  "translate:interpreting exemplify:exemplifying illustrate:exemplifying instantiate:exemplifying "
                  "classify:classifying categorize:classifying subsume:classifying summarize:summarizing "
                  "abstract:summarizing generalize:summarizing infer:inferring conclude:inferring "
                  "extrapolate:inferring interpolate:inferring predict:inferring compare:comparing "
                  "contrast:comparing map:comparing match:comparing explain:explaining describe discuss restate "
                  "rephrase report outline demonstrate relate",
    "apply": "execute:executing carry_out:executing implement:implementing use:implementing apply solve calculate "
             "compute demonstrate operate perform practise practice modify prepare show employ conduct construct test",
    "analyze": "differentiate:differentiating discriminate:differentiating distinguish:differentiating "
               "focus:differentiating select:differentiating organize:organizing find_coherence:organizing "
               "integrate:organizing outline:organizing parse:organizing structure:organizing "
               "attribute:attributing deconstruct:attributing analyze analyse examine investigate break_down "
               "diagram dissect inspect compare contrast classify identify relate",
    "evaluate": "check:checking coordinate:checking detect:checking monitor:checking test:checking "
                "critique:critiquing judge:critiquing evaluate assess appraise argue defend justify rank rate "
                "recommend prioritize verify support conclude select",
    "create": "generate:generating hypothesize:generating plan:planning design:planning produce:producing "
              "construct:producing create compose invent formulate develop devise build author write propose "
              "organize integrate",
}
ES = {
    "remember": "reconocer:recognizing identificar:recognizing recordar:recalling recuperar:recalling enumerar listar "
                "nombrar definir citar memorizar repetir etiquetar reproducir localizar describir",
    "understand": "interpretar:interpreting aclarar:interpreting parafrasear:interpreting representar:interpreting "
                  "traducir:interpreting ejemplificar:exemplifying ilustrar:exemplifying instanciar:exemplifying "
                  "clasificar:classifying categorizar:classifying subsumir:classifying resumir:summarizing "
                  "abstraer:summarizing generalizar:summarizing inferir:inferring concluir:inferring "
                  "extrapolar:inferring interpolar:inferring predecir:inferring comparar:comparing "
                  "contrastar:comparing emparejar:comparing explicar:explaining describir discutir reformular "
                  "exponer esquematizar demostrar relacionar",
    "apply": "ejecutar:executing implementar:implementing usar:implementing utilizar:implementing aplicar resolver "
             "calcular computar demostrar operar realizar practicar modificar preparar mostrar emplear construir probar",
    "analyze": "diferenciar:differentiating discriminar:differentiating distinguir:differentiating "
               "focalizar:differentiating seleccionar:differentiating organizar:organizing integrar:organizing "
               "esquematizar:organizing estructurar:organizing atribuir:attributing deconstruir:attributing "
               "analizar examinar investigar descomponer diagramar inspeccionar comparar contrastar clasificar "
               "identificar relacionar",
    "evaluate": "comprobar:checking coordinar:checking detectar:checking monitorizar:checking probar:checking "
                "criticar:critiquing juzgar:critiquing evaluar valorar argumentar defender justificar jerarquizar "
                "recomendar priorizar verificar apoyar concluir seleccionar",
    "create": "generar:generating hipotetizar:generating planificar:planning diseñar:planning producir:producing "
              "construir:producing crear componer inventar formular desarrollar idear elaborar redactar proponer "
              "organizar integrar",
}
VAGUE_EN = "understand know learn appreciate comprehend realize realise grasp believe internalize internalise " \
           "be_aware become_aware be_familiar become_familiar familiarize think feel"
VAGUE_ES = "entender comprender saber conocer aprender apreciar darse_cuenta familiarizarse creer interiorizar " \
           "pensar sentir ser_consciente tomar_conciencia"
IRREGULAR_ES = {  # forms the regular rules miss: 3rd person present, future, subjunctive
    "recordar": "recuerda recuerdan recuerde", "predecir": "predice predicen predira prediga", "concluir": "concluye concluyen",
    "construir": "construye construyen construya", "atribuir": "atribuye atribuyen", "deconstruir": "deconstruye",
    "componer": "compondra compondran componga", "proponer": "propondra propondran proponga", "resolver": "resuelve resuelven resuelva",
    "demostrar": "demuestra demuestran demuestre", "probar": "prueba prueban pruebe", "mostrar": "muestra muestran muestre",
    "defender": "defiende defienden defienda", "comprobar": "comprueba comprueban compruebe", "distinguir": "distinga",
    "identificar": "identifique identifiquen", "explicar": "explique expliquen", "aplicar": "aplique apliquen",
    "clasificar": "clasifique", "planificar": "planifique", "justificar": "justifique", "verificar": "verifique",
    "ejemplificar": "ejemplifique", "criticar": "critique", "practicar": "practique", "modificar": "modifique",
    "reconocer": "reconozca reconozcan", "producir": "produzca produzcan", "traducir": "traduzca",
    "conocer": "conozca conozcan", "saber": "sabe sabra sepa sepan", "entender": "entiende entienden entienda entiendan",
    "pensar": "piensa piense", "sentir": "siente sienta", "darse_cuenta": "se_da_cuenta se_den_cuenta",
    "ser_consciente": "sea_consciente sera_consciente", "elegir": "elige",
}
PROMPT_EN = {"remember": "list, define, recall", "understand": "explain, summarize, classify",
             "apply": "use, solve, implement"}
PROMPT_ES = {"remember": "enumerar, definir, recordar", "understand": "explicar, resumir, clasificar",
             "apply": "usar, resolver, aplicar"}
STOP_EN = {"the", "and", "of", "to", "will", "be", "able", "students", "student", "with", "a", "an", "in", "their", "how"}
STOP_ES = {"el", "la", "los", "las", "de", "del", "y", "que", "será", "sera", "capaz", "alumno", "alumnado", "estudiante",
           "estudiantes", "con", "en", "un", "una", "podrá", "podra", "sus", "para"}


def fail(message):
    print(json.dumps({"error": message}))
    sys.exit(2)


def plain(text):
    return "".join(c for c in unicodedata.normalize("NFD", text.lower()) if unicodedata.category(c) != "Mn")


def forms_en(verb):
    words = verb.split("_")
    head, tail = words[0], words[1:]
    out = {head, head + "s", head + "es", head + "ed", head + "d", head + "ing"}
    if head.endswith("e"):
        out |= {head[:-1] + "ing", head[:-1] + "ed"}
    if head.endswith("y") and head[-2:-1] not in "aeiou":
        out |= {head[:-1] + "ies", head[:-1] + "ied"}
    if re.fullmatch(r"[^aeiou]*[aeiou][^aeiouwxy]", head):
        out |= {head + head[-1] + "ed", head + head[-1] + "ing"}
    if head.endswith("ize"):
        out |= {f.replace("iz", "is", 1) for f in list(out) if "iz" in f}
    return {"_".join([f] + tail) for f in out}


def forms_es(verb):
    words = verb.split("_")
    head, tail = words[0], words[1:]
    out = {head}
    if head.endswith("se"):  # pronominal infinitive: familiarizarse -> familiarice, se familiariza
        head = head[:-2]
    stem, ending = head[:-2], head[-2:]
    if ending == "ar":
        out |= {stem + e for e in ("a", "an", "ara", "aran", "ando", "ado", "e", "en", "aremos")}
    elif ending in ("er", "ir"):
        vowel = ending[0]
        out |= {stem + e for e in ("e", "en", vowel + "ra", vowel + "ran", "iendo", "ido", "a", "an", vowel + "remos")}
    out.add(head)
    out |= set(IRREGULAR_ES.get(verb, "").split())
    return {"_".join([f] + tail) if "_" not in f else f for f in out}


def build(table, vague, forms):
    entries = {}  # base verb -> {"levels": [...], "process": str|None}
    for level in LEVELS:
        for item in table[level].split():
            verb, _, process = item.partition(":")
            entry = entries.setdefault(verb, {"levels": [], "process": None})
            if process:
                entry["process"] = process
                entry["levels"].insert(0, level)
            elif level not in entry["levels"]:
                entry["levels"].append(level)
    lookup = {}
    for verb, entry in entries.items():
        for form in forms(verb):
            lookup.setdefault(plain(form), (verb, entry))
    vague_lookup = {}
    for verb in vague.split():
        for form in forms(verb):
            vague_lookup.setdefault(plain(form), verb)
    return lookup, vague_lookup


TABLES = {"en": build(EN, VAGUE_EN, forms_en), "es": build(ES, VAGUE_ES, forms_es)}


def detect(text):
    words = re.findall(r"[^\W\d_]+", text.lower())
    es = sum(w in STOP_ES for w in words) + sum(1 for w in words if re.search(r"[áéíóúñ]", w))
    en = sum(w in STOP_EN for w in words)
    return "es" if es > en else "en"


def classify(objective, language):
    lang = detect(objective) if language == "auto" else language
    lookup, vague = TABLES[lang]
    names = LEVEL_NAMES[lang]
    tokens = re.findall(r"[a-z]+", plain(objective))
    found, vague_found, i = [], [], 0
    while i < len(tokens):
        hit = None
        for size in (3, 2, 1):
            key = "_".join(tokens[i:i + size])
            if len(tokens[i:i + size]) == size and (key in lookup or key in vague):  # the table wins over vague
                hit = key, size
                break
        if hit:
            key, size = hit
            if key not in lookup:
                vague_found.append({"verb": vague[key].replace("_", " "), "matched": key.replace("_", " "), "position": len(found) + len(vague_found)})
            else:
                verb, entry = lookup[key]
                found.append({"verb": verb.replace("_", " "), "matched": key.replace("_", " "), "levels": entry["levels"],
                              "process": entry["process"], "position": len(found) + len(vague_found)})
            i += size
        else:
            i += 1
    result = {"objective": objective, "language": lang}
    flags = []
    if vague_found and (not found or vague_found[0]["position"] < found[0]["position"]):
        flags.append("non_measurable")
    if not found:
        result.update({"verb": None, "level": None, "level_name": None, "level_number": None, "ambiguous": False,
                       "non_measurable": bool(vague_found), "process": None})
        if vague_found:
            v = vague_found[0]["verb"]
            hint = PROMPT_ES if lang == "es" else PROMPT_EN
            result["message"] = (f"'{v}' is not observable; restate with a measurable verb, e.g. "
                                 f"{hint['understand']} (Understand) or {hint['remember']} (Remember)")
        else:
            result["message"] = "no verb of the table found; start the objective with an action verb"
        result["non_measurable_verbs"] = [v["verb"] for v in vague_found]
        result["other_verbs"] = []
        return result
    main = found[0]
    level = main["levels"][0]
    result.update({
        "verb": main["verb"], "matched": main["matched"], "level": level, "level_name": names[LEVELS.index(level)],
        "level_number": LEVELS.index(level) + 1, "process": main["process"],
        "ambiguous": len(main["levels"]) > 1, "non_measurable": "non_measurable" in flags,
    })
    if len(main["levels"]) > 1:
        result["also_listed_at"] = [names[LEVELS.index(lv)] for lv in main["levels"][1:]]
        result["message"] = (f"'{main['verb']}' is listed at more than one level; check what the learner does "
                             f"with the content")
    if "non_measurable" in flags:
        result["message"] = (f"starts with the non-measurable '{vague_found[0]['verb']}'; classified by "
                             f"'{main['verb']}', the first measurable verb")
    others = [{"verb": f["verb"], "level": f["levels"][0]} for f in found[1:]]
    result["other_verbs"] = others
    result["highest_level"] = max([level] + [o["level"] for o in others], key=LEVELS.index)
    result["non_measurable_verbs"] = [v["verb"] for v in vague_found]
    return result


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) - {"objectives", "language"})
    if extra:
        fail(f"unknown field(s): {', '.join(extra)}")
    objectives = request.get("objectives")
    if isinstance(objectives, str):
        objectives = [objectives]
    if (not isinstance(objectives, list) or not 1 <= len(objectives) <= 200
            or not all(isinstance(o, str) and o.strip() and len(o) <= 2000 for o in objectives)):
        fail("objectives must be a list of 1 to 200 non-empty strings (each at most 2000 characters)")
    language = request.get("language", "auto")
    if language not in ("en", "es", "auto"):
        fail("language must be en, es or auto")
    results = [classify(o.strip(), language) for o in objectives]
    counts = {lv: sum(r["level"] == lv for r in results) for lv in LEVELS}
    print(json.dumps({
        "results": results,
        "summary": {"by_level": counts, "unclassified": sum(r["level"] is None for r in results),
                    "ambiguous": sum(r["ambiguous"] for r in results),
                    "non_measurable": sum(r["non_measurable"] for r in results)},
        "source": "Revised Bloom taxonomy, cognitive process dimension (Anderson & Krathwohl 2001, Table 5.1); "
                  "common-usage verbs compiled for this tool",
    }, 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/bloom_classify/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/bloom_classify/1.0.0/bloom_classify.py