From 28f69edee3887c2b92efb3527b7e2cf3ca9e8a67 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Mon, 31 Aug 2026 12:39:11 +0100 Subject: [PATCH 1/5] feat: add wizard subcommand (unstructured input -> markdown) `in2lambda wizard INPUT -o draft.md` turns a PDF/docx/tex/md document into the #/## markdown the Markdown filter reads, for a human to review before `in2lambda convert draft.md Markdown`. - wizard/run.py: routes input through Mathpix (PDF) / pandoc (docx) / raw read, runs one LLM extraction pass, renders #/## markdown, echoes check_markdown() warnings, writes the file. - wizard/extract.py: pydantic WizardSet/WizardQuestion/WizardPart + extract_set() via the OpenAI structured-output parse helper against OpenRouter, with a system prompt and one few-shot example. to_markdown() renders the contract Step 3's filter consumes. - main.py: `wizard` command; run.py imported lazily so the rest of the CLI works without the llm extra. - Root conftest.py skips the pydantic-dependent wizard modules from --doctest-modules on a bare install (CI runs --all-extras). - docs/source/wizard.md + toctree, quickstart + README pointers. Slimmed from conversion2025/converter.py on Summer2025: the line-number extraction and the trim/dedupe/evaluate passes are left out of v1; prompt shape informed by wizard/to_question.py on wxyang_hackathon. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017VXb8aZqgFBjoeuuddjW6r --- README.md | 6 ++ conftest.py | 13 ++++ docs/source/index.md | 1 + docs/source/quickstart.md | 2 + docs/source/wizard.md | 47 +++++++++++++ in2lambda/main.py | 32 +++++++++ in2lambda/wizard/extract.py | 132 ++++++++++++++++++++++++++++++++++++ in2lambda/wizard/run.py | 54 +++++++++++++++ tests/test_wizard.py | 112 ++++++++++++++++++++++++++++++ 9 files changed, 399 insertions(+) create mode 100644 conftest.py create mode 100644 docs/source/wizard.md create mode 100644 in2lambda/wizard/extract.py create mode 100644 in2lambda/wizard/run.py create mode 100644 tests/test_wizard.py diff --git a/README.md b/README.md index b91c632..a2afa4e 100644 --- a/README.md +++ b/README.md @@ -10,3 +10,9 @@ Find out more in the [documentation](https://lambda-feedback.github.io/in2lambda ``` $ pip install in2lambda ``` + +To also use `in2lambda wizard` (OCR + LLM extraction of unstructured documents), install the `llm` extra: + +``` +$ pip install 'in2lambda[llm]' +``` diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..a6c0021 --- /dev/null +++ b/conftest.py @@ -0,0 +1,13 @@ +"""Root pytest config: skip modules that need the optional ``llm`` extra when it is absent. + +CI installs ``--all-extras`` so everything runs there; this only keeps +``pytest --doctest-modules`` working on a bare ``poetry install``. +""" + +try: + import pydantic # noqa: F401 +except ImportError: # pragma: no cover + collect_ignore = [ + "in2lambda/wizard/extract.py", + "in2lambda/wizard/run.py", + ] diff --git a/docs/source/index.md b/docs/source/index.md index c6a7658..5896f6a 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -62,6 +62,7 @@ A fully type-annotated extensively documented Python library is available for th 🔎 Overview quickstart filters/index +wizard ``` ```{toctree} diff --git a/docs/source/quickstart.md b/docs/source/quickstart.md index 3d273c4..156e063 100644 --- a/docs/source/quickstart.md +++ b/docs/source/quickstart.md @@ -81,6 +81,8 @@ If you would rather write the questions yourself, the [`Markdown` filter](filter $ in2lambda convert questions.md Markdown ``` +If your source is an unstructured PDF, Word or LaTeX document, [`in2lambda wizard`](wizard.md) can generate that markdown for you to review first. + By default, this generates an `out` directory in the same place that the command was run in. It contains the zipped question files. Check the [command line tool reference](reference/command-line) for more information. diff --git a/docs/source/wizard.md b/docs/source/wizard.md new file mode 100644 index 0000000..b84fe13 --- /dev/null +++ b/docs/source/wizard.md @@ -0,0 +1,47 @@ +# 🪄 Wizard + +The filters expect a document that already has a clear structure. When you only +have a messy PDF, a Word document or a LaTeX problem sheet, `in2lambda wizard` +uses OCR and an LLM to turn it into the plain `#`/`##` markdown that the +[`Markdown` filter](filters/_autosummary/Markdown) understands. + +The wizard **does not** produce Lambda Feedback JSON directly. It writes a +markdown file for you to read and fix, and then you run the normal conversion on +it. + +## Setup + +The wizard needs the optional `llm` extra: + +```bash +$ pip install 'in2lambda[llm]' +``` + +and these environment variables (a `.env` file in the working directory is +picked up automatically): + +| Variable | Needed for | Notes | +| --- | --- | --- | +| `OPENROUTER_API_KEY` | every run | Create one at . | +| `IN2LAMBDA_MODEL` | optional | Default model slug; override per run with `--model`. | +| `MATHPIX_APP_ID`, `MATHPIX_API_KEY` | PDF input only | From . | + +## Usage + +```bash +$ in2lambda wizard problem_sheet.pdf -o draft.md +``` + +`draft.md` now contains one `#` heading per question, `## Part N` headings for +sub-questions, and `## Solution` blocks. Any figures found in a PDF are saved +next to it under `media/`. + +Read through `draft.md`, fix anything the model got wrong, then convert it: + +```bash +$ in2lambda convert draft.md Markdown +``` + +:::{note} +`.docx`, `.tex` and `.md` inputs skip the OCR step and go straight to the LLM. +::: diff --git a/in2lambda/main.py b/in2lambda/main.py index 400b302..eb2c9e4 100644 --- a/in2lambda/main.py +++ b/in2lambda/main.py @@ -220,5 +220,37 @@ def convert( runner(question_file, chosen_filter, output_dir, answer_file) +@cli.command(no_args_is_help=True) +@click.argument( + "input_file", type=click.Path(exists=True, dir_okay=False, resolve_path=True) +) +@click.option( + "--out", + "-o", + "output_file", + default="./wizard.md", + show_default=True, + help="Markdown file to write for review.", + type=click.Path(resolve_path=True), +) +@click.option( + "--model", + "-m", + default=None, + help="OpenRouter model slug (default: $IN2LAMBDA_MODEL or a built-in default).", +) +def wizard(input_file: str, output_file: str, model: Optional[str]) -> None: + """Turn an unstructured INPUT_FILE (PDF/docx/tex/md) into #/## markdown for review. + + Needs the 'llm' extra (pip install 'in2lambda[llm]') and an OPENROUTER_API_KEY. + Review the output, then run: in2lambda convert OUTPUT Markdown + """ + # Imported lazily so the rest of the CLI works without the optional llm extra. + from in2lambda.wizard.run import run_wizard + + written = run_wizard(input_file, output_file, model) + click.echo(f"Wrote {written}") + + if __name__ == "__main__": cli() diff --git a/in2lambda/wizard/extract.py b/in2lambda/wizard/extract.py new file mode 100644 index 0000000..81ad161 --- /dev/null +++ b/in2lambda/wizard/extract.py @@ -0,0 +1,132 @@ +"""Use an LLM to pull a structured question set out of unstructured markdown. + +This is the single-pass extraction used by ``in2lambda wizard``: the whole +document goes in, a :class:`WizardSet` comes back, and :func:`to_markdown` +renders it in the ``#``/``##`` form the :mod:`Markdown filter +` reads. +""" + +from typing import Optional + +from pydantic import BaseModel, Field + + +class WizardPart(BaseModel): + """One sub-question and its worked solution.""" + + text: str = Field(description="The sub-question text, verbatim, without its label.") + solution: str = Field( + default="", description="Worked solution for this part; empty if none is given." + ) + + +class WizardQuestion(BaseModel): + """A whole question: a stem, optional parts, and solutions.""" + + title: str = Field(description="A short title for the question.") + text: str = Field(description="The question stem, before any sub-questions.") + parts: list[WizardPart] = Field( + default_factory=list, description="Sub-questions such as (a), (b), i., ii." + ) + solution: str = Field( + default="", + description="Worked solution when the question has no parts; empty otherwise.", + ) + + +class WizardSet(BaseModel): + """Every question found in the document.""" + + questions: list[WizardQuestion] + + +_SYSTEM_PROMPT = """\ +You extract questions from problem sheets and lecture material. + +Return every question with: +- a short title, +- its stem (the text before any sub-questions), +- its parts - sub-questions such as (a), (b) or i., ii. - each with the worked + solution for that part, +- or, if the question has no parts, a single worked solution for the whole + question. + +Copy mathematics and LaTeX exactly, keeping $...$ and $$...$$ delimiters. Do not +invent content: if a solution is not present, leave it empty. Do not include +question or part numbering in the text.\ +""" + +_FEWSHOT_INPUT = """\ +Question 3. A ball is dropped from rest from a height $h$. +(a) Find the time it takes to reach the ground. +(b) Find its speed on impact. + +Solution. +(a) $t = \\sqrt{2h/g}$. +(b) $v = \\sqrt{2gh}$.\ +""" + +_FEWSHOT_OUTPUT = ( + '{"questions": [{"title": "Ball dropped from height h", ' + '"text": "A ball is dropped from rest from a height $h$.", ' + '"parts": [{"text": "Find the time it takes to reach the ground.", ' + '"solution": "$t = \\\\sqrt{2h/g}$."}, ' + '{"text": "Find its speed on impact.", "solution": "$v = \\\\sqrt{2gh}$."}], ' + '"solution": ""}]}' +) + + +def extract_set(source_markdown: str, client, model: str) -> WizardSet: + """Ask ``model`` (via ``client``) to turn ``source_markdown`` into a WizardSet. + + Args: + source_markdown: The document to extract from (markdown or LaTeX text). + client: An OpenAI-compatible client, e.g. from + :func:`in2lambda.llm.get_client`. + model: The model slug to use. + + Returns: + The extracted question set. + + Raises: + RuntimeError: if the model does not return a parseable set. + """ + completion = client.beta.chat.completions.parse( + model=model, + messages=[ + {"role": "system", "content": _SYSTEM_PROMPT}, + {"role": "user", "content": _FEWSHOT_INPUT}, + {"role": "assistant", "content": _FEWSHOT_OUTPUT}, + {"role": "user", "content": source_markdown}, + ], + response_format=WizardSet, + ) + + parsed: Optional[WizardSet] = completion.choices[0].message.parsed + if parsed is None: + raise RuntimeError("The model did not return a parseable question set.") + return parsed + + +def to_markdown(question_set: WizardSet) -> str: + """Render a :class:`WizardSet` as ``#``/``##`` markdown for the Markdown filter.""" + blocks: list[str] = [] + + for question in question_set.questions: + blocks.append(f"# {question.title.strip()}") + if question.text.strip(): + blocks.append(question.text.strip()) + + if question.parts: + for index, part in enumerate(question.parts, start=1): + blocks.append(f"## Part {index}") + if part.text.strip(): + blocks.append(part.text.strip()) + if part.solution.strip(): + blocks.append("## Solution") + blocks.append(part.solution.strip()) + elif question.solution.strip(): + blocks.append("## Solution") + blocks.append(question.solution.strip()) + + return "\n\n".join(blocks) + "\n" diff --git a/in2lambda/wizard/run.py b/in2lambda/wizard/run.py new file mode 100644 index 0000000..c72faad --- /dev/null +++ b/in2lambda/wizard/run.py @@ -0,0 +1,54 @@ +"""Drive the wizard: unstructured document in, ``#``/``##`` markdown out. + +The output is written for a human to review and tweak before running it through +``in2lambda convert ... Markdown``. +""" + +from pathlib import Path +from typing import Optional + +import rich_click as click + +from in2lambda.llm import get_client, resolve_model +from in2lambda.main import docx_to_md, file_type +from in2lambda.validation import check_markdown +from in2lambda.wizard.extract import extract_set, to_markdown +from in2lambda.wizard.mathpix import pdf_to_markdown + + +def _load_markdown(source: Path, media_dir: Path) -> str: + """Return ``source`` as markdown/LaTeX text, running OCR for PDFs.""" + if source.suffix.lower() == ".pdf": + return pdf_to_markdown(str(source), str(media_dir)).read_text(encoding="utf-8") + if file_type(str(source)) == "docx": + return docx_to_md(str(source)) + return source.read_text(encoding="utf-8") + + +def run_wizard(input_file: str, output_file: str, model: Optional[str] = None) -> Path: + """Convert ``input_file`` (PDF/docx/tex/md) to reviewable markdown at ``output_file``. + + Args: + input_file: The unstructured source document. + output_file: Where to write the ``#``/``##`` markdown. + model: OpenRouter model slug; defaults to ``$IN2LAMBDA_MODEL`` or the + built-in default. + + Returns: + The path to the written markdown file. + """ + source = Path(input_file) + output = Path(output_file) + output.parent.mkdir(parents=True, exist_ok=True) + + source_markdown = _load_markdown(source, output.parent) + + client = get_client() + question_set = extract_set(source_markdown, client, resolve_model(model)) + markdown = to_markdown(question_set) + + for problem in check_markdown(markdown): + click.echo(f"Warning: {problem.value}") + + output.write_text(markdown, encoding="utf-8") + return output diff --git a/tests/test_wizard.py b/tests/test_wizard.py new file mode 100644 index 0000000..51a25f5 --- /dev/null +++ b/tests/test_wizard.py @@ -0,0 +1,112 @@ +"""Tests for ``in2lambda wizard``: extraction is mocked, no network calls.""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from in2lambda.main import runner +from in2lambda.wizard.extract import WizardPart, WizardQuestion, WizardSet, to_markdown + +SAMPLE_SET = WizardSet( + questions=[ + WizardQuestion( + title="Projectile", + text="A ball is thrown from height $h$.", + parts=[ + WizardPart(text="Find the flight time.", solution="$t=\\sqrt{2h/g}$."), + WizardPart(text="Find the range.", solution="$x=v_0 t$."), + ], + ), + WizardQuestion( + title="Newton's second law", + text="State the law.", + solution="$F = ma$.", + ), + ] +) + + +def _fake_client(question_set: WizardSet) -> MagicMock: + client = MagicMock() + client.beta.chat.completions.parse.return_value.choices = [ + SimpleNamespace(message=SimpleNamespace(parsed=question_set)) + ] + return client + + +def test_to_markdown_roundtrips_through_markdown_filter(tmp_path): + md_file = tmp_path / "wizard.md" + md_file.write_text(to_markdown(SAMPLE_SET)) + + result = runner(str(md_file), "Markdown") + + assert [q.title for q in result.questions] == ["Projectile", "Newton’s second law"] + projectile = result.questions[0] + assert projectile.main_text == "A ball is thrown from height $h$." + assert [p.text for p in projectile.parts] == [ + "Find the flight time.", + "Find the range.", + ] + assert projectile.parts[0].worked_solution == "$t=\\sqrt{2h/g}$." + assert result.questions[1].parts[0].worked_solution == "$F = ma$." + + +def test_run_wizard_writes_reviewable_markdown(tmp_path, monkeypatch): + from in2lambda.wizard import run as run_module + + monkeypatch.setattr(run_module, "get_client", lambda: _fake_client(SAMPLE_SET)) + monkeypatch.setattr(run_module, "resolve_model", lambda value: "test/model") + + source = tmp_path / "raw.md" + source.write_text("some messy notes with a $x$ here") + out = tmp_path / "out" / "reviewed.md" + + written = run_module.run_wizard(str(source), str(out)) + + assert written == out + text = out.read_text() + assert text.startswith("# Projectile") + assert "## Part 1" in text and "## Solution" in text + # The extracted markdown must feed straight back into the Markdown filter. + assert len(runner(str(out), "Markdown").questions) == 2 + + +def test_run_wizard_uses_mathpix_for_pdfs(tmp_path, monkeypatch): + from in2lambda.wizard import run as run_module + + pdf = tmp_path / "paper.pdf" + pdf.write_bytes(b"%PDF-1.4 fake") + ocr_markdown = tmp_path / "out" / "paper.md" + + def fake_pdf_to_markdown(src, out_dir): + path = ocr_markdown + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("# raw ocr\n\nstuff") + return path + + seen = {} + + def fake_extract(source_markdown, client, model): + seen["source"] = source_markdown + return SAMPLE_SET + + monkeypatch.setattr(run_module, "pdf_to_markdown", fake_pdf_to_markdown) + monkeypatch.setattr(run_module, "get_client", lambda: MagicMock()) + monkeypatch.setattr(run_module, "extract_set", fake_extract) + + run_module.run_wizard(str(pdf), str(tmp_path / "out" / "reviewed.md")) + + assert seen["source"] == "# raw ocr\n\nstuff" + + +def test_extract_set_raises_when_model_returns_nothing(): + from in2lambda.wizard.extract import extract_set + + client = MagicMock() + client.beta.chat.completions.parse.return_value.choices = [ + SimpleNamespace(message=SimpleNamespace(parsed=None)) + ] + + with pytest.raises(RuntimeError, match="parseable"): + extract_set("anything", client, "test/model") From ca9d127bf59ac08527f49808022100d97a203a91 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 1 Sep 2026 18:08:50 +0100 Subject: [PATCH 2/5] feat: add support for naming question sets with `--name` option - Extend `runner()` and `convert` CLI to accept an optional `--name` (`-n`) parameter for naming question sets. - Sanitize `set_name` into a filesystem-safe "slug" for output paths. - Update JSON generation to use the sanitized name in filenames (`set_.json`) and directories. - Add tests for named sets and update documentation to reflect the new functionality. --- docs/source/wizard.md | 8 ++++++++ in2lambda/json_convert/json_convert.py | 7 +++++-- in2lambda/main.py | 20 +++++++++++++++++-- tests/test_cli.py | 27 ++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 4 deletions(-) diff --git a/docs/source/wizard.md b/docs/source/wizard.md index b84fe13..46f693d 100644 --- a/docs/source/wizard.md +++ b/docs/source/wizard.md @@ -42,6 +42,14 @@ Read through `draft.md`, fix anything the model got wrong, then convert it: $ in2lambda convert draft.md Markdown ``` +Every set is called `set` unless you say otherwise. Pass `--name` (`-n`) to give +it a distinct name, which is what Lambda Feedback shows on import and also names +the `set_.json` / `.zip` output: + +```bash +$ in2lambda convert draft.md Markdown --name "Problem Sheet 4" +``` + :::{note} `.docx`, `.tex` and `.md` inputs skip the OCR step and go straight to the LLM. ::: diff --git a/in2lambda/json_convert/json_convert.py b/in2lambda/json_convert/json_convert.py index dd85f23..900534d 100644 --- a/in2lambda/json_convert/json_convert.py +++ b/in2lambda/json_convert/json_convert.py @@ -48,10 +48,13 @@ def converter( ListQuestions = SetQuestions.questions set_name = SetQuestions._name set_description = SetQuestions._description + # The name is used both as a path component and as the set file's suffix, so + # strip anything that isn't filesystem-safe (mirrors the question filenames below). + set_slug = re.sub(r"[^\w\-_.]", "_", set_name.strip()) or "set" # create directory to put the questions os.makedirs(output_dir, exist_ok=True) - output_question = os.path.join(output_dir, set_name) + output_question = os.path.join(output_dir, set_slug) os.makedirs(output_question, exist_ok=True) set_template["name"] = set_name @@ -66,7 +69,7 @@ def converter( SetQuestions._structuredTutorialVisibility.status ) # create the set file - with open(f"{output_question}/set_{set_name}.json", "w") as file: + with open(f"{output_question}/set_{set_slug}.json", "w") as file: json.dump(set_template, file) for i in range(len(ListQuestions)): diff --git a/in2lambda/main.py b/in2lambda/main.py index eb2c9e4..59a31ef 100644 --- a/in2lambda/main.py +++ b/in2lambda/main.py @@ -88,6 +88,7 @@ def runner( chosen_filter: str, output_dir: Optional[str] = None, answer_file: Optional[str] = None, + set_name: Optional[str] = None, ) -> Set: r"""Takes in a TeX file for a given subject and outputs how it's broken down within Lambda Feedback. @@ -96,6 +97,7 @@ def runner( chosen_filter: The filter chosen to parse the TeX file. output_dir: An optional argument for where to output the Lambda Feedback compatible json/zip files. answer_file: The absolute path to a TeX answer file. + set_name: An optional name for the question set. Defaults to "set" when not provided. Returns: A list of questions and how they would be broken down into different Lambda Feedback sections @@ -113,6 +115,8 @@ def runner( """ # The list of questions for Lambda Feedback as a Python API. set_obj = Set() + if set_name is not None: + set_obj.set_name(set_name) # Dynamically import the correct pandoc filter depending on the subject. filter_module = importlib.import_module(f"in2lambda.filters.{chosen_filter}.filter") @@ -212,12 +216,24 @@ def cli() -> None: help="File containing solutions for QUESTION_FILE.", type=click.Path(resolve_path=True, exists=True, dir_okay=False), ) +@click.option( + "--name", + "-n", + "set_name", + default=None, + help="Name for the question set (default: 'set'). Determines the set_.json " + "filename and the name Lambda Feedback shows on import.", +) def convert( - question_file: str, chosen_filter: str, output_dir: str, answer_file: Optional[str] + question_file: str, + chosen_filter: str, + output_dir: str, + answer_file: Optional[str], + set_name: Optional[str], ) -> None: """Take a QUESTION_FILE and CHOSEN_FILTER and produce Lambda Feedback json/zip files.""" # Kept separate from runner() so runner() can be imported as part of the library. - runner(question_file, chosen_filter, output_dir, answer_file) + runner(question_file, chosen_filter, output_dir, answer_file, set_name) @cli.command(no_args_is_help=True) diff --git a/tests/test_cli.py b/tests/test_cli.py index 28ff15d..51b2c0a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -46,6 +46,33 @@ def test_convert_accepts_case_insensitive_filter_and_markdown( assert (out_dir / "set" / "set_set.json").is_file() +def test_convert_name_option_sets_set_name(filters_dir: str, tmp_path) -> None: + import json + + example = os.path.join(filters_dir, "Markdown", "example.md") + out_dir = tmp_path / "out" + + result = CliRunner().invoke( + cli, + [ + "convert", + example, + "Markdown", + "-o", + str(out_dir), + "--name", + "Problem Sheet 4", + ], + ) + + assert result.exit_code == 0, result.output + # The name is slugified for paths but kept verbatim in the set JSON. + assert (out_dir / "Problem_Sheet_4" / "set_Problem_Sheet_4.json").is_file() + assert (out_dir / "Problem_Sheet_4.zip").is_file() + with open(out_dir / "Problem_Sheet_4" / "set_Problem_Sheet_4.json") as file: + assert json.load(file)["name"] == "Problem Sheet 4" + + def test_convert_rejects_unknown_filter(filters_dir: str, tmp_path) -> None: example = os.path.join(filters_dir, "PartsSepSol", "example.tex") From 22c682e7ccb0695ee44a09f74d8b0597eab5ac7b Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 1 Sep 2026 18:15:35 +0100 Subject: [PATCH 3/5] feat: repair mangled LaTeX commands in JSON-parsed questions - Add `_demangle` function to restore LaTeX control words whose backslashes were lost to JSON un-escaping. - Adjust `extract_set` to apply `_demangle` to question titles, text, solutions, and parts. - Add test to verify proper handling of mangled LaTeX commands and preservation of newlines. --- in2lambda/wizard/extract.py | 30 ++++++++++++++++++++++++++++++ tests/test_wizard.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/in2lambda/wizard/extract.py b/in2lambda/wizard/extract.py index 81ad161..c045efe 100644 --- a/in2lambda/wizard/extract.py +++ b/in2lambda/wizard/extract.py @@ -6,6 +6,7 @@ ` reads. """ +import re from typing import Optional from pydantic import BaseModel, Field @@ -76,6 +77,27 @@ class WizardSet(BaseModel): ) +# ``\t``, ``\r``, ``\f`` and ``\b`` are all valid JSON string escapes, so a model +# that emits ``\text`` / ``\rho`` / ``\frac`` / ``\beta`` with a single backslash +# in its structured output has that backslash swallowed: the parsed JSON then +# holds a bare control character glued to the rest of the command (``ext``, +# ``rac`` ...). None of those control characters is ever real text in a +# problem sheet, so a C0 control character immediately followed by a letter can +# only be a mangled LaTeX control word - put the backslash back. Newlines are +# left untouched: they carry real structure in the extracted text. +_CTRL_ESCAPES = {"\t": r"\t", "\r": r"\r", "\f": r"\f", "\b": r"\b"} +_MANGLED_COMMAND = re.compile(r"([\t\r\f\b])(?=[A-Za-z])") + + +def _demangle(text: str) -> str: + r"""Restore LaTeX control words whose backslash was lost to JSON un-escaping. + + A TAB/CR/FF/BS glued to a letter (e.g. ``ext{m}`` from ``\text``) becomes + ``\`` + that escape letter again. Newlines are deliberately left as-is. + """ + return _MANGLED_COMMAND.sub(lambda match: _CTRL_ESCAPES[match.group(1)], text) + + def extract_set(source_markdown: str, client, model: str) -> WizardSet: """Ask ``model`` (via ``client``) to turn ``source_markdown`` into a WizardSet. @@ -105,6 +127,14 @@ def extract_set(source_markdown: str, client, model: str) -> WizardSet: parsed: Optional[WizardSet] = completion.choices[0].message.parsed if parsed is None: raise RuntimeError("The model did not return a parseable question set.") + + for question in parsed.questions: + question.title = _demangle(question.title) + question.text = _demangle(question.text) + question.solution = _demangle(question.solution) + for part in question.parts: + part.text = _demangle(part.text) + part.solution = _demangle(part.solution) return parsed diff --git a/tests/test_wizard.py b/tests/test_wizard.py index 51a25f5..87c9856 100644 --- a/tests/test_wizard.py +++ b/tests/test_wizard.py @@ -110,3 +110,32 @@ def test_extract_set_raises_when_model_returns_nothing(): with pytest.raises(RuntimeError, match="parseable"): extract_set("anything", client, "test/model") + + +def test_extract_set_repairs_json_unescaped_latex_commands(): + from in2lambda.wizard.extract import extract_set + + # A model that under-escapes "\text"/"\frac"/"\beta"/"\rho" in its structured + # output lands a bare TAB/FF/BS/CR glued to the rest of the command here. + mangled = WizardSet( + questions=[ + WizardQuestion( + title="Units and symbols", + text="Height $h = 45\\,\text{m}$ at angle $\theta$.", # TAB from \t + parts=[ + WizardPart( + text="State the coefficient.\nKeep this newline.", + solution="$\frac12$ with $\beta$ and $\rho$.", # FF, BS, CR + ) + ], + ) + ] + ) + + result = extract_set("anything", _fake_client(mangled), "test/model") + question = result.questions[0] + + assert question.text == "Height $h = 45\\,\\text{m}$ at angle $\\theta$." + assert question.parts[0].solution == "$\\frac12$ with $\\beta$ and $\\rho$." + # Real newlines must survive - only control chars glued to letters are touched. + assert question.parts[0].text == "State the coefficient.\nKeep this newline." From 3d21a35784a21f37763d01e8c3a38c75b9698cf8 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 1 Sep 2026 18:17:50 +0100 Subject: [PATCH 4/5] Updated gitignore --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index 28cb702..5a38e33 100644 --- a/.gitignore +++ b/.gitignore @@ -687,3 +687,9 @@ log **/_autosummary *.pdf /tex + +# Sphinx build output (see also docs/_build/ above) +docs/_bt/ + +# Scratch directory for manual wizard/convert end-to-end runs +/e2e/ From 55dbf09754e2061aba6e930c1280571114a6752b Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 1 Sep 2026 18:39:19 +0100 Subject: [PATCH 5/5] Updated gitignore --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 61d676c..f9e90c0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.10', '3.11'] + python-version: ['3.11'] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }}