Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]'
```
13 changes: 13 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -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",
]
1 change: 1 addition & 0 deletions docs/source/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ A fully type-annotated extensively documented Python library is available for th
🔎 Overview <self>
quickstart
filters/index
wizard
```

```{toctree}
Expand Down
2 changes: 2 additions & 0 deletions docs/source/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
55 changes: 55 additions & 0 deletions docs/source/wizard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# 🪄 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 <https://openrouter.ai/keys>. |
| `IN2LAMBDA_MODEL` | optional | Default model slug; override per run with `--model`. |
| `MATHPIX_APP_ID`, `MATHPIX_API_KEY` | PDF input only | From <https://mathpix.com/ocr>. |

## 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
```

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_<name>.json` / `<name>.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.
:::
7 changes: 5 additions & 2 deletions in2lambda/json_convert/json_convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)):
Expand Down
52 changes: 50 additions & 2 deletions in2lambda/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -212,12 +216,56 @@ 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_<name>.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)
@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__":
Expand Down
162 changes: 162 additions & 0 deletions in2lambda/wizard/extract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
"""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
<in2lambda.filters.Markdown.filter>` reads.
"""

import re
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": ""}]}'
)


# ``\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 (``<TAB>ext``,
# ``<FF>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. ``<TAB>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.

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.")

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


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"
Loading
Loading