X-S5-01 Concept item check

Field Value
Purpose Check an assessment concept item JSON file for a malformed id, a duplicate id, or fewer than two misconceptions, then print a summary.
Usage python3 scripts/s5/concept_item_check.py –help In a shell: python3 scripts/s5/concept_item_check.py ITEMS.json
Dependencies stdlib
Writes files no
License CC0-1.0
Inputs An assessment concept item JSON file: a list of items, each with an id, a chapter, a section, a description, a cognitive level, a Bloom level, key concepts, at least two misconceptions, references and dependencies.
Outputs One line per finding, then a summary line, printed to standard output.
Used in S5.1 Misconception-to-distractor bridge
Source scripts/s5/concept_item_check.py

Source code

"""
ID: X-S5-01
Title: Concept item check
Stage: S5
Purpose: Check an assessment concept item JSON file for a malformed id,
    a duplicate id, or fewer than two misconceptions, then print a
    summary.
Usage: python3 scripts/s5/concept_item_check.py --help
    In a shell: python3 scripts/s5/concept_item_check.py ITEMS.json
Dependencies: stdlib
Writes files: no
License: CC0-1.0
Inputs: An assessment concept item JSON file: a list of items, each
    with an id, a chapter, a section, a description, a cognitive
    level, a Bloom level, key concepts, at least two misconceptions,
    references and dependencies.
Outputs: One line per finding, then a summary line, printed to
    standard output.

This script checks whether an item's id is shaped ACI-<chapter>-<NNN>,
whether that id is used more than once, and whether an item names at
least two misconceptions, and nothing more. It does not judge whether a
description stays atomic, whether a reference is specific enough to
check, or whether a dependency id actually names another item; those
stay a person's own read against the eight-point checklist, not
something this script can decide from the file alone.

Errors (exit 1): fewer than 2 misconceptions; a malformed id (not
ACI-<chapter>-<NNN>, where the chapter and the sequence number are
both digits and the sequence number is exactly three digits); a
duplicate id.
"""

import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any

sys.dont_write_bytecode = True
if sys.version_info < (3, 10):
    print(
        "concept_item_check.py: this script needs Python 3.10 or newer, "
        f"but this is {sys.version_info.major}.{sys.version_info.minor}. "
        "Run it with a newer python3.",
        file=sys.stderr,
    )
    sys.exit(2)

MAX_BYTES = 5_000_000
MIN_MISCONCEPTIONS = 2
ID_RE = re.compile(r"^ACI-[0-9]+-[0-9]{3}$")


def load_items(path: Path) -> Any:
    """Read and parse the items file; never follows a symlink."""
    if path.is_symlink():
        raise OSError(f"refusing to read a symlink: {path}")
    size = path.stat().st_size
    if size > MAX_BYTES:
        raise ValueError(f"{path} is over {MAX_BYTES} bytes; skipping")
    text = path.read_text(encoding="utf-8", errors="replace")
    return json.loads(text)


def validate_items(data: Any) -> list[dict[str, Any]]:
    """Check the top-level shape; raise ValueError on anything malformed.

    Only the shape a finding could not otherwise describe is checked
    here: a top-level list, each entry a JSON object, each entry with a
    real id. A bad id shape, a repeated id or too few misconceptions
    are findings instead, reported by check_items below, not raised
    here.
    """
    if not isinstance(data, list):
        raise ValueError("the items file is not a JSON list")
    items: list[dict[str, Any]] = []
    for position, entry in enumerate(data, start=1):
        if not isinstance(entry, dict):
            raise ValueError(f"item #{position} is not a JSON object")
        item_id = entry.get("id")
        if not isinstance(item_id, str) or not item_id:
            raise ValueError(f"item #{position} has no id")
        items.append(entry)
    return items


def check_items(items: list[dict[str, Any]]) -> list[str]:
    """Return one finding line per gap, malformed id or duplicate id."""
    findings: list[str] = []
    seen: dict[str, bool] = {}
    for entry in items:
        item_id = entry["id"]
        misconceptions = entry.get("misconceptions")
        count = len(misconceptions) if isinstance(misconceptions, list) else 0
        if count < MIN_MISCONCEPTIONS:
            plural = "" if count == 1 else "s"
            findings.append(
                f'gap: "{item_id}" has {count} misconception{plural}, '
                f"needs at least {MIN_MISCONCEPTIONS}"
            )
        if not ID_RE.fullmatch(item_id):
            findings.append(
                f'malformed-id: "{item_id}" is not shaped ACI-<chapter>-<NNN>'
            )
        if item_id in seen:
            findings.append(f'duplicate-id: "{item_id}" is used more than once')
        else:
            seen[item_id] = True
    return findings


def build_parser() -> argparse.ArgumentParser:
    """Build the argument parser."""
    parser = argparse.ArgumentParser(
        prog="concept_item_check.py",
        description=(
            "Check an assessment concept item JSON file for a malformed id, "
            "a duplicate id, or fewer than two misconceptions. Writes no "
            "files."
        ),
    )
    parser.add_argument("items", metavar="ITEMS", help="path to the items JSON")
    return parser


def main(argv: list[str] | None = None) -> int:
    """Command line entry point; prints the report, returns an exit code."""
    parser = build_parser()
    args = parser.parse_args(argv)
    try:
        data = load_items(Path(args.items))
        items = validate_items(data)
    except (
        OSError,
        UnicodeError,
        ValueError,
        json.JSONDecodeError,
        RecursionError,
    ) as exc:
        print(f"concept_item_check.py: error: {exc}", file=sys.stderr)
        return 2
    findings = check_items(items)
    for message in findings:
        print(message)
    print(f"items={len(items)} errors={len(findings)}")
    return 1 if findings else 0


if __name__ == "__main__":
    sys.exit(main())

To the extent possible under law, copyright and related rights in this work are waived under CC0 1.0 Universal.

This site uses Just the Docs, a documentation theme for Jekyll.