fullmark 0.10.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. fullmark/__init__.py +1 -0
  2. fullmark/agents.md +117 -0
  3. fullmark/blanks.py +71 -0
  4. fullmark/brief.py +571 -0
  5. fullmark/brief_questions.yaml +239 -0
  6. fullmark/build.py +233 -0
  7. fullmark/checking.py +48 -0
  8. fullmark/cli.py +1123 -0
  9. fullmark/examples/assets/cubic.png +0 -0
  10. fullmark/examples/assets/ielts-chart.png +0 -0
  11. fullmark/examples/blank.md +131 -0
  12. fullmark/examples/calculus-1.md +157 -0
  13. fullmark/examples/exam-mode.md +100 -0
  14. fullmark/examples/ielts-writing.md +50 -0
  15. fullmark/examples/make_assets.py +87 -0
  16. fullmark/examples/medical-physiology.md +132 -0
  17. fullmark/examples/rust-basics.md +217 -0
  18. fullmark/examples/survey.md +79 -0
  19. fullmark/grade_run.py +109 -0
  20. fullmark/grades.py +112 -0
  21. fullmark/grading.py +314 -0
  22. fullmark/guide.md +367 -0
  23. fullmark/inspect_quiz.py +97 -0
  24. fullmark/library/__init__.py +5 -0
  25. fullmark/library/commands.py +1316 -0
  26. fullmark/library/config.py +83 -0
  27. fullmark/library/fsutil.py +60 -0
  28. fullmark/library/git.py +125 -0
  29. fullmark/library/pretty.py +285 -0
  30. fullmark/library/quizzes.py +379 -0
  31. fullmark/library/store.py +69 -0
  32. fullmark/lint.py +98 -0
  33. fullmark/mdutil.py +41 -0
  34. fullmark/models.py +501 -0
  35. fullmark/numparse.py +28 -0
  36. fullmark/parser.py +510 -0
  37. fullmark/render.py +129 -0
  38. fullmark/report.py +913 -0
  39. fullmark/review.py +506 -0
  40. fullmark/schemas/grades-v1.schema.json +27 -0
  41. fullmark/schemas/report-v1.schema.json +114 -0
  42. fullmark/schemas/results-v1.schema.json +185 -0
  43. fullmark/schemas/review-v1.schema.json +39 -0
  44. fullmark/serve.py +533 -0
  45. fullmark/static/quiz.css +1574 -0
  46. fullmark/static/quiz.js +2738 -0
  47. fullmark/stats.py +148 -0
  48. fullmark/templates/quiz.html.j2 +48 -0
  49. fullmark/templates_cmd.py +101 -0
  50. fullmark/theme.py +135 -0
  51. fullmark/validate.py +495 -0
  52. fullmark/vendor/katex/LICENSE +21 -0
  53. fullmark/vendor/katex/VERSION +1 -0
  54. fullmark/vendor/katex/contrib/auto-render.min.js +1 -0
  55. fullmark/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  56. fullmark/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  57. fullmark/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  58. fullmark/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  59. fullmark/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  60. fullmark/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  61. fullmark/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  62. fullmark/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  63. fullmark/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  64. fullmark/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  65. fullmark/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  66. fullmark/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  67. fullmark/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  68. fullmark/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  69. fullmark/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  70. fullmark/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  71. fullmark/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  72. fullmark/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  73. fullmark/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  74. fullmark/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  75. fullmark/vendor/katex/katex.min.css +1 -0
  76. fullmark/vendor/katex/katex.min.js +1 -0
  77. fullmark-0.10.0.dist-info/METADATA +181 -0
  78. fullmark-0.10.0.dist-info/RECORD +81 -0
  79. fullmark-0.10.0.dist-info/WHEEL +4 -0
  80. fullmark-0.10.0.dist-info/entry_points.txt +3 -0
  81. fullmark-0.10.0.dist-info/licenses/LICENSE +21 -0
fullmark/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.10.0"
fullmark/agents.md ADDED
@@ -0,0 +1,117 @@
1
+ # AGENTS.md: working with fullmark
2
+
3
+ fullmark turns one Markdown file into one offline, self-grading HTML quiz. This file is for AI agents. It covers **using** fullmark (Jobs 1, 2 and 4: write, grade and file quizzes) and **changing** it (Job 3: develop the tool).
4
+
5
+ ## Quick facts
6
+
7
+ - CLI: `quiz` (installed with `uv tool install --editable <path to this repo>`). In the repo, use `uv run quiz ...`.
8
+ - Full authoring reference: run `quiz guide`. This file: run `quiz agent-guide` (it works without the repo). It is the source of truth for syntax. Read it before writing a quiz; do not guess.
9
+ - Examples to copy from: `examples/calculus/`, `examples/rust/`, `examples/ielts/`, `examples/medical/`, `examples/exam/` (each has `quiz.md`, a built `quiz.html`, and `assets/` where needed). `quiz new calculus|rust|ielts|medical|exam|blank [PATH]` copies a template and never overwrites.
10
+ - Exit codes for `check` and `build`: 0 = ok (warnings allowed), 1 = quiz errors, 2 = usage or I/O problem. With `--strict`, any warning is also 1 (use it in CI; `build --strict` writes nothing).
11
+ - Nothing needs the network. Do not add network requests to the page; tests assert there are none.
12
+
13
+ ## Job 1: author a quiz
14
+
15
+ 1. Read the source material, then run `quiz guide`.
16
+ 2. Write `quiz.md`: YAML front matter (`title` required; quote it if it contains a colon), optional `#` sections, `##` questions.
17
+ 3. Run `quiz check quiz.md` (add `--json` to parse findings). Output is `file:line: error|warning: qN (type): message`. Fix every error; fix warnings too, usually a missing `> [!explanation]`. `quiz check` also lints for weak questions (short explanation, "all of the above", a correct option much longer than the rest, answer in the question text, skewed answer positions). Fix those too; they are the usual agent mistakes. `quiz inspect quiz.md` shows types, points, sections, tags and answer positions, so you can check coverage against the source material.
18
+ 4. While iterating, `quiz watch quiz.md` rebuilds on every save (reload the page after each rebuild, or use `--serve` to have the browser reload by itself).
19
+ 5. Run `quiz build quiz.md` to write `quiz.html` beside it (`-o OUT` to choose, `--open` to launch it). Give the learner that one file.
20
+
21
+ Rules that avoid most errors:
22
+
23
+ - Choice questions: every item needs `[ ]` or `[x]`. One `[x]` is single, two or more is multi.
24
+ - `truefalse`, `numeric`, `short`, `fields`, `essay` and `code` need a fenced block whose info string is `answer`, containing YAML (`value`, `tolerance`, `accept`, `patterns`, `min_words`, `rubric`, `language`, `starter`).
25
+ - `ordering`: write an ordered list in the **correct** order (the page shuffles it). `matching`: bullets `left :: right`; a bullet `:: right` is a distractor. Neither takes an `answer` block. Both follow `multi_scoring` (`partial` or `all_or_nothing`).
26
+ - `blanks` use inline `[[answer|variant]]`, also inside code blocks.
27
+ - Heading attributes go at the end of the `##` line: `{type=numeric points=2 id=q-area tags=limits}`. Tags have no spaces; they drive the per-topic report.
28
+ - Numeric answers are compared as numbers, not symbolically. For `short` answers list every accepted spelling.
29
+ - Question banks: write extra questions and set `pick: N` (front matter) or `{pick=N}` on sections so each attempt draws a random subset. The export records the draw (`attempt.bank`) and `quiz grade` checks it. Use it for practice drills, not when everyone must see the same questions.
30
+ - Questionnaires: `mode: survey` (front matter) makes every question ungraded and the page shows no score; `graded=false` on a `##` heading does that for one question; `{type=scale}` is a rating row. Ungraded questions are `single`, `multi`, `short`, `fields`, `essay`, `ordering` (a ranking) and `scale`; write no `[x]`, no `answer` block on `short`, no rubric on `essay`. Use a survey instead of asking more than about five questions in chat, or when the user wants to keep the answers. Start from `quiz new survey`. When you are waiting for the answers, collect them directly: run `quiz serve quiz.md --once --open --json` in the background (it exits 0 when the respondent presses Finish, 2 on `--timeout`) and read the results file named in its JSON output; never start it in a way that blocks you.
31
+ - Follow-up text on a choice: end a `single` or `multi` choice with `{input}` or `{input="What is it?"}` (default prompt "Please specify") to ask for a few words when it is picked, for example "Something else". It works in quizzes and surveys, graded or not, and never changes the score. The export adds `response.inputs` (`{"2": "text"}`, original choice indices) and `quiz grade` shows `Something else: text` (`answer_text`, plus the raw `inputs`, in the JSON report). The marker must end the line and the prompt needs double quotes; on any other question type it is an error.
32
+ - Brief: for a new quiz, look for `brief.yaml` in the quiz folder first and read it. It holds the purpose, scope, material (`content/`), count, mix, difficulty, mode, weak topics (`focus_tags`) and language. If `request` is filled in, do not ask the brief questions again. The human-only values are **purpose and scope**: if `brief.yaml` is missing and the user has not said them, ask, or run `quiz brief DIR --web` for them; `quiz brief DIR ...` exits 3 until both are given, and `--accept-defaults` never gives you the scope. Use `--accept-defaults` only when the user handed those choices over, and list what you chose. When `past-exams` is ticked, mirror their structure (types, split of marks, length) and cite them in explanations as `Source: file, question N`. `quiz check` warns when the quiz drifts from the brief (`brief.count`, `brief.mix`, `brief.past-exams`, `brief.content-kinds`).
33
+ - Review what you wrote: an agent-written quiz can have a wrong key or a question that is not in the material, and only the learner can judge that. After writing a quiz from material, offer `quiz review quiz.md`; when the user agrees, run `quiz review quiz.md --once --open --json` in the background and wait for it (exit 0 when the learner presses Finish, 2 on `--timeout`). Its JSON lists `flagged` questions (verdict, text, problems, note) and counts. Fix what is flagged **by changing the source**, then run `quiz check quiz.md --review review.json`: `review.drop`, `review.fix` and `review.key-wrong` are errors until the questions change. Offer `quiz review quiz.md --since review.json` for the changed ones. `review: true` in front matter is set only by the generated review form; never write it in a quiz. Never write or edit `review.json` yourself: it is the learner's verdict (it says `"by": "quiz review"`), and the check cannot tell. Give questions `{id=...}` so a review survives reordering; matching otherwise uses a hash of the question text.
34
+ - Put images in `assets/` and reference them relatively (`![alt](assets/x.png)`); they are embedded as data URIs. No video, no web URLs.
35
+ - Give every auto-graded question an explanation. Write essay and code rubrics concretely, because they are what you will grade against later.
36
+ - The answer key is in the HTML, only lightly obfuscated. Never describe the quiz as secure.
37
+
38
+ ## Job 2: grade results
39
+
40
+ The learner presses Submit, then Export answers, which produces `<id>-results-<date>.json`. Its JSON Schema is `src/fullmark/schemas/results-v1.schema.json`; the `--json` report has `report-v1.schema.json` beside it. Both carry a `version`; new optional fields keep it, so ignore keys you do not know.
41
+
42
+ ```powershell
43
+ quiz grade results.json --quiz quiz.md # writes results-report.md next to the results
44
+ quiz grade results.json --quiz quiz.md --json # writes results-report.json instead (preferred for agents)
45
+ ```
46
+
47
+ - A learner can press **Retry missed** after Submit. That export has `attempt.retry_of` (the earlier attempt's `started_at`) and `attempt.bank.drawn` lists only the missed questions; `quiz grade` accepts it without a draw warning.
48
+ - Python re-grades from the quiz source and is the authority. If it disagrees with the browser score it warns and uses its own number. It also warns if `quiz.md` changed after the attempt.
49
+ - Prefer `--json`. It has `summary`, `by_topic` and a `questions` list; each question has `status`, `earned`, `points`, `your_answer`, `correct_answer`, `explanation`, `needs_review` and `needs_ai_grading`. Essay, code and ungraded-field questions also carry an `ai_grading` object: `rubric`, `criteria`, `scoring`, and the learner's `response` (plus `word_count`, `language` and so on).
50
+ - Ungraded questions (`graded=false`, `scale`, every question in a survey) have `status: "ungraded"` (or `unanswered`), `earned: null`, `ungraded: true`, the raw `response` and a readable `answer_text`; they are never in `needs_review` or `needs_ai_grading`, and the grades template never lists them. To read a survey: `quiz grade results.json --quiz quiz.md --json` and read `answer_text` per question (`attempt.mode` and the top-level `mode` say `survey`); the Markdown report of a survey is titled "Answers" and has no score.
51
+ - The Markdown report has a summary, a per-tag table, a per-question table, an **Answers** section (only when the quiz has ungraded questions), **Review these** (wrong or partial answers with correct answer and explanation), and **Pending AI review**.
52
+ - Your job is the **Pending AI review** section: for each essay and code answer, read the learner's response against the rubric and fill in the empty **Grading** block (score out of the question's points, short justification, one concrete improvement). Code answers are never executed; judge by reading.
53
+ - Preferred way to record grades: `quiz grade results.json --quiz quiz.md --json` also writes `<results>-grades.json`, an empty template (schema `grades-v1.schema.json`). Fill `score` (0 to the question's points), `band` (0-9, half steps), `criteria`, `justification` and `improvement` for each id, then run the same command with `--grades <file>`. Errors (unknown id, score out of range, bad band) exit 1. The report then carries each grade under `ai_grading.result` and `summary.final_earned`, `final_possible` and `final_percent`. Band questions are reported but never add to the points. An existing template is never overwritten.
54
+ - To see progress over several attempts at one quiz: `quiz stats a.json b.json c.json [--quiz quiz.md] [--json]` prints scores by date, per-topic percent per attempt with the change from first to last, and the three weakest topics of the latest attempt, plus an "Answers over time" table for `scale` and ungraded `single` questions (ungraded questions never enter the scores; surveys work too). Different quizzes exit 2; a changed quiz only warns.
55
+ - Then summarize for the learner: weak tags first, what to revisit, and corrections for each wrong answer. Do not edit the learner's answers or the JSON.
56
+ - Essays with `scoring: band` (set in the `answer` block, IELTS style) ask for a 0-9 band; give one per rubric criterion if the rubric lists criteria.
57
+
58
+ In Claude Code the `/quiz` skill wraps both jobs. Its source is `skill/SKILL.md` in this repo (linked into `~/.claude/skills/quiz`); `tests/test_skill.py` fails if it names a command, flag or template the CLI does not have.
59
+
60
+ ## Job 3: develop fullmark
61
+
62
+ Layout (`src/fullmark/`):
63
+
64
+ | Path | Role |
65
+ |---|---|
66
+ | `parser.py`, `models.py`, `validate.py` | Markdown to pydantic models, with line numbers; all error messages |
67
+ | `grading.py` and `static/quiz.js` | The same grading rules twice (Python authority, JS in the page). Change both together |
68
+ | `render.py`, `build.py`, `theme.py`, `templates/`, `static/quiz.css` | The HTML page. Colors live only in `theme.py`; the CSS uses `var(--name)` |
69
+ | `report.py` | Markdown report |
70
+ | `checking.py`, `serve.py` | The findings behind `quiz check`; the local servers (`watch --serve`, `serve`, `review`, `take`) and `serve_loop` |
71
+ | `library/` | `quiz library`: config, registry, git, terminal tables. It imports core; core never imports it (`tests/test_layers.py`); `cli.py` imports it only inside the `library` handler |
72
+ | `cli.py`, `templates_cmd.py`, `guide.md`, `agents.md` | CLI, `quiz new`, the text printed by `quiz guide` and `quiz agent-guide` |
73
+ | `examples/` | Packaged templates used by `quiz new` and the tests |
74
+ | `vendor/katex/` | Vendored KaTeX 0.16.47. Do not edit |
75
+
76
+ Top-level `examples/` holds copies of the three example quizzes with built HTML. They are not read by the package. The packaged files are the source. After editing a packaged example, `quiz.css` or `quiz.js`, run `uv run python scripts/sync_examples.py` to copy them over and rebuild the HTML; a test fails when they drift. The same script copies `src/fullmark/agents.md` (the source of this file) to the root `AGENTS.md`; edit the packaged one.
77
+
78
+ Workflow:
79
+
80
+ ```powershell
81
+ uv sync
82
+ uv run pytest -q # full suite, about 5 min, includes real-browser tests
83
+ uv run pytest -q --ignore=tests/e2e # skip the browser tests
84
+ ```
85
+
86
+ - Grader changes: add a golden case (`uv run python tests/make_grading_cases.py`); cases run through both Python and JS and must agree.
87
+ - New validation error: add one fixture (`tests/fixtures/make_fixtures.py`).
88
+ - Docs: `docs/GUIDE.md` (user guide), `docs/SPEC.md` (design decisions), `src/fullmark/guide.md` (authoring reference). Keep the syntax tables in `guide.md` and `GUIDE.md` consistent, then run `uv run python docs/build_docs.py` to rebuild the HTML.
89
+ - Example images: `uv run python src/fullmark/examples/make_assets.py`.
90
+ - UI rules the tests enforce: no network requests, contrast limits in light and dark, keyboard access. Do not use color alone to signal right or wrong; badges carry a symbol.
91
+ - Style: Python is linted by ruff, JS/JSON by Biome, Markdown by markdownlint. A hook formats edited files; fix what it reports.
92
+
93
+ Working rules (every change):
94
+
95
+ - Read this file before changing anything.
96
+ - One task, one commit. Run `uv run pytest -q` before every commit; it must pass. Known flakes under load, rerun alone before treating them as failures: `tests/e2e/test_panel.py::test_narrow_screen_keeps_progress_and_pills_in_the_header` and Windows socket aborts (WinError 10053).
97
+ - A change to syntax updates `src/fullmark/guide.md` **and** `docs/GUIDE.md`. A change to the page (CSS, JS, template) or a packaged example ends with `uv run python scripts/sync_examples.py`.
98
+ - Put all localStorage access through the `store()` wrapper in `quiz.js`; it copes with blocked storage.
99
+ - Keep `ruff check`, `ruff format --check`, `biome lint src/fullmark/static/quiz.js` and `ty check src/fullmark/library src/fullmark/serve.py` clean.
100
+ - A new dependency changes the installed tool: say in the README that `uv tool install --editable <path to this repo> --reinstall` is needed.
101
+ - Never touch the user's real library or config (`quiz library path` shows it) except to read it; tests and manual checks use a scratch `FULLMARK_CONFIG`, `GIT_CONFIG_GLOBAL` and `GIT_CONFIG_NOSYSTEM` (the `lib` fixture in `tests/conftest.py`).
102
+ - Stop and ask the user before anything that needs an account, a remote or a publish. Never push; never publish to a package index.
103
+ - Plans: the roadmap is `docs/PLANNED.md` (strike items through when done: `~~item~~: done`). A work order for a larger change goes in `docs/plans/NAME_PLAN.md` and gets a line in `docs/plans/README.md`. The root holds only `README.md` and `AGENTS.md`.
104
+
105
+ ## Job 4: the library
106
+
107
+ `quiz library` keeps the user's own quizzes and results in one folder under git. Commands: `init`, `status`, `register`, `list`, `take`, `share`, `path`, `grade`, `stats`, `rename`, `rm`. Exit codes: 0 done, 1 problems or a refused quiz, 2 usage error or no library, 3 needs input. The tool commits after each write and **never pushes**. Everything below is the contract; the user's library may or may not exist.
108
+
109
+ - **Check first.** `quiz library status --json`: `status` is `no_library` when nothing is set up (exit 2), `ok` or `problems` otherwise. Read it; never change the library or the config by hand.
110
+ - **The loop** for a quiz you write: `quiz check quiz.md` (fix errors and warnings), optionally `quiz review`, then `quiz library register quiz.md --json`. No `quiz build` is needed for the user's own quizzes; the `.md` and its `assets/` can sit in your scratch folder. Images must be inside the quiz's folder and referenced relatively.
111
+ - **Exit 3 means ask.** With no library, `register` exits 3 with `{"status": "needs_input", "missing": [...], "fix": "quiz library init PATH"}`. Ask the user where the library goes. **Never run `init` yourself**: without PATH it makes the current folder (often the project) the library, and the location is theirs to choose. `init` refuses a folder that has other files (exit 2); give it a new or empty one, for example `D:\quizzes`. Pass `--force` only after the user said yes to switching libraries.
112
+ - **A name clash** exits 1 and suggests a free name: register again with `-n` and that name, and tell the user which name you used. The default name is the slugged title, and the JSON says when it was defaulted (`name_defaulted`).
113
+ - **Changing a quiz** is explicit. Export the source with `quiz library share NAME --source -o DIR`, edit the file it prints, run `quiz check`, then `quiz library register DIR/NAME/FILE.md --update NAME`. Never guess which quiz a file belongs to, and never edit files inside the library. Registering the same file again is safe: `unchanged`, or `replaced` while nobody has taken that version.
114
+ - **`register --json`** prints `{"status": "registered"|"replaced"|"new-version"|"updated-assets"|"unchanged", "id", "name", "name_defaulted", "hash", "version_count", "committed", "take": "quiz library take <name>"}`. **End your message with the `take` line** so the user can start the quiz.
115
+ - **Grading.** Results are in `quiz library path NAME`/results, one JSON file per attempt, named by date and time. Grade with `quiz library grade NAME --json` (the newest attempt, or `--attempt STAMP`): it finds the version the attempt was taken on and writes `reports/<stamp>-report.json`, plus an empty `reports/<stamp>-grades.json` when essays or code need grading. Fill that template, then run `quiz library grade NAME --grades reports/<stamp>-grades.json --json`. Reports go in `reports/`, never `results/`. `quiz library stats NAME [--json]` shows progress over all attempts. The results files are the learner's: read them, do not edit them.
116
+ - **`rm` and `rename` are the user's call.** Never run `quiz library rm` or `rename` unless the user asked for that exact quiz. `rm` deletes the quiz with its versions and results (git can revert it); never pass `--yes` or `--force` on your own.
117
+ - **Never push**, and never run git commands in the library. Results hold the learner's answers and the snapshots hold the answer keys, so any remote the user adds must be private; that is their step.
fullmark/blanks.py ADDED
@@ -0,0 +1,71 @@
1
+ """Inline blanks: [[answer|variant]] and numeric [[=value±tolerance]]."""
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+
6
+ from .numparse import parse_number
7
+
8
+ BLANK_RE = re.compile(r"\[\[(.+?)\]\]")
9
+ _NUMERIC_RE = re.compile(r"^=\s*(\S+?)\s*(?:(?:±|\+-)\s*(\S+))?$")
10
+
11
+
12
+ @dataclass
13
+ class Blank:
14
+ raw: str
15
+ variants: list[str] = field(default_factory=list)
16
+ numeric: bool = False
17
+ value: float | None = None
18
+ tolerance: float = 0.0
19
+ start: int = 0
20
+ end: int = 0
21
+
22
+ def spec(self) -> dict:
23
+ if self.numeric:
24
+ return {"numeric": True, "value": self.value, "tolerance": self.tolerance}
25
+ return {"numeric": False, "accept": self.variants}
26
+
27
+
28
+ def parse_blank(inner: str) -> Blank:
29
+ """Parse the text between [[ and ]]. Raises ValueError with a message on bad syntax."""
30
+ if inner.startswith("="):
31
+ m = _NUMERIC_RE.match(inner)
32
+ value = parse_number(m.group(1)) if m else None
33
+ if value is None:
34
+ raise ValueError(f"bad numeric blank '[[{inner}]]'; use [[=0.5±0.01]]")
35
+ tol = 0.0
36
+ if m.group(2) is not None:
37
+ t = parse_number(m.group(2))
38
+ if t is None or t < 0:
39
+ raise ValueError(f"bad tolerance in '[[{inner}]]'")
40
+ tol = t
41
+ return Blank(raw=inner, numeric=True, value=value, tolerance=tol)
42
+ parts = [p.strip() for p in re.split(r"(?<!\\)\|", inner)]
43
+ parts = [p.replace("\\|", "|") for p in parts]
44
+ if not all(parts):
45
+ raise ValueError(f"empty answer in blank '[[{inner}]]'")
46
+ return Blank(raw=inner, variants=parts)
47
+
48
+
49
+ def find_blanks(text: str) -> list[Blank]:
50
+ out = []
51
+ for m in BLANK_RE.finditer(text):
52
+ b = parse_blank(m.group(1))
53
+ b.start, b.end = m.start(), m.end()
54
+ out.append(b)
55
+ return out
56
+
57
+
58
+ def blank_bucket(blanks: list[Blank]) -> str:
59
+ """Start-width bucket (s|m|l|xl) for a question, from its longest accepted answer.
60
+
61
+ One bucket per question, so no single blank reveals its own answer length.
62
+ """
63
+ longest = 0
64
+ for b in blanks:
65
+ texts = [f"{b.value:g}"] if b.numeric else b.variants
66
+ longest = max([longest, *(len(t) for t in texts)])
67
+ if longest <= 5:
68
+ return "s"
69
+ if longest <= 9:
70
+ return "m"
71
+ return "l" if longest <= 15 else "xl"