Skip to content
Merged
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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ the one beside `SOURCE` whose name is the sheet's with `_solutions`, `-solutions
and `Worksheet_1_solutions.pdf`. Both are frozen into the one draft, the questions
first and the solutions second, and the run is named after the questions file whether
you name that file or the solutions one. A solutions file with no questions file
beside it stops the run, which exits 1 saying `solutions without questions`.
beside it is converted on its own, and a `pair` line names the questions file the
agent looked for. The marker above each group of solutions is that run's question.

The run writes a spec — the YAML selectors naming which blocks of the source are
questions, parts and solutions — and saves it as `in2lambda-spec.yaml` beside `SOURCE`.
Expand Down Expand Up @@ -284,8 +285,8 @@ pdf` runs the PDFs too, which needs Mathpix credentials and one call per PDF. Ev
is review mode `none`.

A sheet and the solutions file beside it are one run and one row, named after the
questions file. A solutions file with no questions file beside it is a `skipped` row
with the reason `solutions without questions`.
questions file. A solutions file with no questions file beside it is converted on its
own, and is a row like any other document.

The sweep never writes to the corpus. It copies each set's folder into `--work`
(default `./.in2lambda-agent/corpus`), empties that copy first, and runs the documents
Expand Down
17 changes: 15 additions & 2 deletions docs/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ review not asked for (mode none)
build /home/me/sheets/out/set.zip
```

There are ten stage names: `ocr`, `freeze`, `spec`, `coverage`, `validate`, `set`,
`fix`, `render`, `review` and `build`. The spec loop prints `freeze`, `spec`,
There are eleven stage names: `pair`, `ocr`, `freeze`, `spec`, `coverage`, `validate`,
`set`, `fix`, `render`, `review` and `build`. A run prints `pair` only for a solutions
file with no questions file beside it. The spec loop prints `freeze`, `spec`,
`coverage`, `validate` and `set` once per try, and a run prints `validate` once per
check and `fix` once per fixing round, so those names repeat. Each line is printed as
the run makes it, so a `--tries 3` run prints seven lines before its second model
Expand All @@ -43,6 +44,7 @@ and no error behind.

| Stage | in2lambda function | What the stage writes |
| --- | --- | --- |
| `pair` | none: the agent reads the file names | nothing |
| `ocr` | none: Mathpix converts the PDF | `CACHE/HASH/source.md` and `CACHE/HASH/media/` |
| `freeze` | `in2lambda.source.add` | `SOURCE.draft.json`, beside the frozen source |
| `spec` | `in2lambda.source.show`, for the model's prompt | `in2lambda-spec.yaml`, beside `SOURCE` or at `--spec` |
Expand All @@ -61,6 +63,17 @@ A run also appends one line to `in2lambda-agent-runs.jsonl`, beside the spec. Th
appends that line last, whether it wrote a zip or ended with a report. A run that stops
for a review appends no line: the approval that completes the review appends it.

### `pair`

A sheet and the solutions file beside it are one run, named after the questions file.
The stage prints one message, and only where the source is a solutions file that the
folder holds no questions file for:

* `no questions file named Tutorial_2.pdf beside Tutorial_2_Solutions.pdf; converting
the solutions alone` — the run converts the solutions file as a document of its own.
The spec prompt then says that the document holds solutions and no questions, so the
model writes a `question` selector for the marker above each group of solutions.

### `ocr`

The stage converts a PDF to markdown through Mathpix. A source of any other suffix is
Expand Down
5 changes: 1 addition & 4 deletions in2lambda_agent/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
from in2lambda_agent.model import ModelError, ModelUnavailable, choose_backend
from in2lambda_agent.ocr import ocr_pdf
from in2lambda_agent.package import CommandRefused, SpecRejected
from in2lambda_agent.pair import SolutionsWithoutQuestions
from in2lambda_agent.review import ReviewError
from in2lambda_agent.settings import load_settings
from in2lambda_agent.spec import BadSpec
Expand Down Expand Up @@ -449,13 +448,11 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
SpecRejected,
ReviewError,
CommandRefused,
SolutionsWithoutQuestions,
) as error:
# Missing credentials among them: the message names the variables, or
# the login to run, or what the provider said stopped a call, or what a
# spec says that a spec cannot say, or the question a review command
# names that is not under review, or the questions file a solutions file
# was run without.
# names that is not under review.
print(f"in2lambda-agent: {error}", file=sys.stderr)
return 1

Expand Down
19 changes: 6 additions & 13 deletions in2lambda_agent/corpus.py
Original file line number Diff line number Diff line change
Expand Up @@ -431,19 +431,12 @@ def sweep(
continue
# A solutions document is frozen into the run of the questions document
# it answers, so the pair is one row, which is the questions document's.
# One with no questions document beside it has no questions to attach
# its solutions to, and is a row of its own saying so.
if pair.questions_stem(document) is not None:
if pair.questions_beside(document) is not None:
continue
row = Row(
source=relative.as_posix(),
set=relative.parent.as_posix(),
outcome="skipped",
reason="solutions without questions",
)
print(f"{row.outcome:<20} {row.source}")
rows.append(row)
# One with no questions document beside it is converted on its own, and
# is a row like any other document.
if (
pair.questions_stem(document) is not None
and pair.questions_beside(document) is not None
):
continue
# Staging is per set and the row is per document, so the guard is here
# rather than in `run_one`: what a copy raises — an unreadable folder, a
Expand Down
26 changes: 8 additions & 18 deletions in2lambda_agent/pair.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"""Which document holds the solutions to which, going by the file names.

A folder of worksheets often writes the questions and the solutions as separate
documents: `Worksheet_1.pdf` beside `Worksheet_1_solutions.pdf`. A solutions
document alone holds no questions for its solutions to answer, so the run
documents: `Worksheet_1.pdf` beside `Worksheet_1_solutions.pdf`. The run
freezes the two into one draft, the questions first and the solutions second,
and is named after the questions document.
and is named after the questions document. A solutions document with no
questions document beside it is converted on its own.

The pairing is by name. A solutions document is one whose stem ends in
`solutions` after a space, an underscore or a hyphen, in any case. Its questions
Expand All @@ -24,10 +24,6 @@
"""


class SolutionsWithoutQuestions(ValueError):
"""A solutions document has no questions document beside it."""


def questions_stem(document: Path) -> Optional[str]:
"""The stem of the questions document a solutions document answers.

Expand Down Expand Up @@ -110,20 +106,14 @@ def of(source: Path) -> tuple[Path, Optional[Path]]:

Returns:
The questions document, and the solutions document to freeze after it,
or None where the folder holds no solutions document for it.

Raises:
SolutionsWithoutQuestions: `source` is a solutions document and the
folder holds no questions document for it.
or None where the folder holds no solutions document for it. A
solutions document with no questions document beside it is returned as
the first of the two, so the run converts it on its own.
"""
source = Path(source)
stem = questions_stem(source)
if stem is None:
if questions_stem(source) is None:
return source, solutions_beside(source)
questions = questions_beside(source)
if questions is None:
raise SolutionsWithoutQuestions(
f"solutions without questions: nothing named {stem}{source.suffix} "
f"beside {source.name}"
)
return source, None
return questions, source
14 changes: 12 additions & 2 deletions in2lambda_agent/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,6 @@ def run(
BadSpec: If what the model answers with is not a spec.
SpecRejected: If in2lambda will not run the spec.
SourceError: If in2lambda cannot freeze or check the source.
SolutionsWithoutQuestions: If `source` is a solutions file and no
questions file is beside it.
"""
# A relative --out means the directory the user ran from, whatever in2lambda
# does with the working directory along the way.
Expand All @@ -201,11 +199,22 @@ def run(
# file's from here on, whichever of the two the user named.
source, solutions = pair.of(source)

# A solutions file with no questions file beside it is converted on its
# own. Its questions are the markers written above its solutions, which the
# spec prompt says so that the model writes `question` selectors for them.
alone = pair.questions_stem(source) if solutions is None else None

# The set is the folder the user's file is in, so this is settled before
# OCR moves a PDF's markdown off into the cache.
saved = spec_path(source, spec)

result = RunResult(on_stage=on_stage)
if alone is not None:
result.add_stage(
"pair",
f"no questions file named {alone}{source.suffix} beside "
f"{source.name}; converting the solutions alone",
)

# The rest of the pipeline reads markdown, so a PDF becomes markdown first.
frozen, _, message = _markdown(
Expand Down Expand Up @@ -284,6 +293,7 @@ def run(
previous=previous,
solutions=frozen_solutions,
solutions_name=solutions.name if solutions is not None else "",
solutions_only=alone is not None,
)
except ModelError as error:
# Which call did not finish, for a caller that names it: a spec call
Expand Down
18 changes: 18 additions & 0 deletions in2lambda_agent/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ def write_spec(
backend: Backend,
previous: Optional[Previous] = None,
sources: int = 1,
solutions_only: bool = False,
) -> tuple[str, Reply]:
"""Writes a spec for a source, in one model call with no tools.

Expand All @@ -252,6 +253,10 @@ def write_spec(
where this call is a revision of that spec.
sources: How many documents the draft holds: 2 where the solutions are
a file of their own, which the prompt then says before the source.
solutions_only: Whether the one document is a file of solutions with no
questions file beside it, which the prompt then says before the
source, because the questions of such a document are the markers
written above its solutions.

Returns:
The spec, and the reply it came in.
Expand All @@ -267,6 +272,15 @@ def write_spec(
"are `b1` onwards, and its solutions file, whose blocks are `2/b1` "
"onwards. Every solution is in the second.\n\n"
)
elif solutions_only:
prompt = (
"This document holds solutions and no questions, and there is no "
"second source. The marker written above each group of solutions — "
"the `Q2.` or the `## Question 2` — is the question here, and its "
"text is that question's text. Write `question` to match every "
"marker, and `solution` to match the worked solutions under it. "
"`layout` describes this file.\n\n"
)
prompt += f"Here is the source, one line each with its block id:\n\n{shown}\n"
if previous is not None:
prompt += _revision(previous)
Expand Down Expand Up @@ -309,6 +323,7 @@ def iterate_spec(
previous: Optional[Previous] = None,
solutions: Optional[Path] = None,
solutions_name: str = "",
solutions_only: bool = False,
) -> tuple[Path, Coverage, Report, list[SpecTry]]:
"""Writes the set's spec up to `tries` times and saves the best of them.

Expand Down Expand Up @@ -341,6 +356,8 @@ def iterate_spec(
solutions: The markdown of the solutions document, frozen into the same
draft as the second source, where the sheet has one.
solutions_name: That document's file name, which the freeze line names.
solutions_only: Whether `frozen` is a file of solutions with no
questions file beside it, which every call is told.

Returns:
The draft the chosen spec filled, what that spec covered, what the
Expand Down Expand Up @@ -395,6 +412,7 @@ def iterate_spec(
backend,
previous,
sources=2 if solutions is not None else 1,
solutions_only=solutions_only,
)
saved.write_text(text, encoding="utf-8")
tokens = reply.usage.input_tokens + reply.usage.output_tokens
Expand Down
8 changes: 8 additions & 0 deletions tests/fixtures/lone-spec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# The spec the fake backend answers with for a file of solutions that has no
# questions file beside it. The marker above each solution is the question, so
# the `question` selector matches the markers and the `solution` selector the
# paragraphs under them.
ignore: Header
question: Para text~'^Q\d'
solution: after Header text=Solutions, Para
layout: PartsOneSol
11 changes: 11 additions & 0 deletions tests/fixtures/lone_solutions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Tutorial Sheet 5: answers

## Solutions

Q1.

$\omega = v / r$

Q2.

$T = 2\pi\sqrt{m/k}$
22 changes: 10 additions & 12 deletions tests/test_corpus.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import pytest
from conftest import FakeBackend
from test_pipeline import FAULTY_SPEC, FIXES, PAIRED_SPEC, SPEC, TEX_SPEC
from test_pipeline import FAULTY_SPEC, FIXES, LONE_SPEC, PAIRED_SPEC, SPEC, TEX_SPEC

from in2lambda_agent import corpus, pipeline
from in2lambda_agent.model import ModelError
Expand Down Expand Up @@ -542,21 +542,19 @@ def test_a_sheet_and_its_solutions_file_are_one_row(tmp_path):
]


def test_solutions_with_no_questions_beside_them_are_skipped(tmp_path):
def test_solutions_with_no_questions_beside_them_are_converted_alone(tmp_path):
root = tmp_path / "corpus"
folder = root / "worksheets"
folder.mkdir(parents=True)
shutil.copy(
FIXTURES / "paired_solutions.md", folder / "Tutorial_2_Solutions.md"
)
backend = FakeBackend(PAIRED_SPEC)
shutil.copy(FIXTURES / "lone_solutions.md", folder / "Tutorial_2_Solutions.md")
backend = FakeBackend(LONE_SPEC)

rows = sweep(root, tmp_path, backend=backend)

# `skipped` is an outcome the command exits 0 on, as a drawing's row is.
assert [(row.source, row.outcome, row.reason) for row in rows] == [
("worksheets/Tutorial_2_Solutions.md", "skipped", "solutions without questions")
# A document of solutions is a set of its own, so the sweep runs it and
# records the row it built.
assert [(row.source, row.set, row.outcome) for row in rows] == [
("worksheets/Tutorial_2_Solutions.md", "worksheets", "built")
]
# Nothing was frozen or called on its account.
assert len(backend.calls) == 0
assert not (tmp_path / "work" / "worksheets").exists()
assert len(backend.calls) == 1
assert (tmp_path / "work" / "worksheets" / "Tutorial_2_Solutions.md").is_file()
2 changes: 1 addition & 1 deletion tests/test_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ def test_readme_names_every_option():

def test_how_it_works_names_every_stage():
names = _stage_names()
assert len(names) == 10
assert len(names) == 11
# The stage's own section, rather than the name anywhere on the page: a
# column of the corpus table shares a name with a stage.
missing = [one for one in sorted(names) if f"### `{one}`" not in HOW_IT_WORKS]
Expand Down
16 changes: 8 additions & 8 deletions tests/test_pair.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,16 @@ def test_a_folder_that_is_not_there_holds_no_companion(tmp_path):
assert pair.questions_beside(absent / "Worksheet_1_solutions.pdf") is None
assert pair.solutions_beside(absent / "Worksheet_1.pdf") is None
assert pair.of(absent / "Worksheet_1.pdf") == (absent / "Worksheet_1.pdf", None)
with pytest.raises(pair.SolutionsWithoutQuestions):
pair.of(absent / "Worksheet_1_solutions.pdf")
assert pair.of(absent / "Worksheet_1_solutions.pdf") == (
absent / "Worksheet_1_solutions.pdf",
None,
)


def test_solutions_with_no_questions_say_what_is_missing(tmp_path):
def test_solutions_with_no_questions_run_alone(tmp_path):
# The markers above the solutions are this document's questions, so the
# document converts with no second file.
solutions = tmp_path / "Tutorial_2_Solutions.pdf"
solutions.write_bytes(b"%PDF")

with pytest.raises(
pair.SolutionsWithoutQuestions,
match="solutions without questions: nothing named Tutorial_2.pdf",
):
pair.of(solutions)
assert pair.of(solutions) == (solutions, None)
Loading
Loading