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
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 @@ -235,12 +239,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
Loading
Loading