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
23 changes: 13 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,16 +311,19 @@ This serves one page on `http://127.0.0.1:8765/` and opens it; `--no-open` print
address and opens nothing. The page lists `--corpus` — `./ExampleContents` by default,
or the current directory where there is no such folder — one directory at a time:
click a folder to list it, and a document to pick it. A source elsewhere goes into the
box by hand. Set the options the `run` command takes, and press Go. Each stage line
arrives on the page as the stage finishes, with the tokens
and seconds of each model call. A run in review mode stops with its questions, each
beside its rendered PDF, and approve, reject and edit answer them without leaving the
page; the stages of a rejection's fixing rounds arrive the same way. When the run
ends, the page links to the zip, the rendered PDFs, the draft, the spec and the run
record.

It is a harness for trying the agent by hand. It listens on this machine only, has no
authentication, and runs one run at a time.
box by hand.

The page runs the `convert` command: the two routes, the reconciliation and the build.
Name the solutions document, or leave that box empty for the document beside the source;
name a Lua filter for route B, or tick Write filter for one model call that writes
`filter.lua` into the out directory; then press Go. Each stage line — `ocr`, `route A`,
`route B`, `fields`, `build` — arrives on the page as the stage finishes. When the run
ends, the page shows each flagged field with the reason it is flagged and each route's
reading of it, the counts of the reconciliation, the tokens, and links to the zip and to
the filter where the run wrote one.

The page is a harness for trying the agent by hand. It listens on this machine only, has
no authentication, and runs one conversion at a time.

### Checking the OCR against the page

Expand Down
75 changes: 58 additions & 17 deletions in2lambda_agent/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional
from typing import Any, Callable, Optional

from in2lambda.api.part import Part
from in2lambda.api.question import Question
Expand Down Expand Up @@ -419,23 +419,27 @@ def report(self) -> list[str]:
lines.append(f"flag {one.field}: {one.reason}")
if one.a and one.b:
lines += [f" A: {_squash(one.a)}", f" B: {_squash(one.b)}"]
if self.fields:
counts = _counted(
[self.fields, self.agreed, self.defaulted, self.adjudicated, len(self.flags)]
)
else:
# No filter was given, or the filter run failed: no field was compared, and
# every field is route A's. The counts of a comparison that did not happen
# say nothing, so the line counts route A's fields instead.
ran = "failed" if self.route_b_error else "did not run"
counts = f"{len(fields(normalise(self.reply)))} fields, route B {ran}"
lines.append(f"fields {counts}")
lines.append(f"fields {self.counted()}")
if self.route_b_error:
lines.append(f"route B failed: {self.route_b_error}")
if self.zip_path:
lines.append(f"build {self.zip_path}")
return lines

def counted(self) -> str:
"""The `fields` line of the report, which the page shows as its own line too.

Where no filter was given, or the filter run failed, no field was compared, and
every field is route A's. The counts of a comparison that did not happen say
nothing, so the line counts route A's fields instead.
"""
if self.fields:
return _counted(
[self.fields, self.agreed, self.defaulted, self.adjudicated, len(self.flags)]
)
ran = "failed" if self.route_b_error else "did not run"
return f"{len(fields(normalise(self.reply)))} fields, route B {ran}"


_UNDERLINE = Path(__file__).parent / "underline.lua"

Expand Down Expand Up @@ -463,6 +467,22 @@ def markdown_of(document: Path, cache_dir: Path, settings: Settings) -> tuple[st
return out.stdout.decode("utf-8"), document.parent


def _read_as(document: Path, cache_dir: Path) -> str:
"""How one document's markdown is got, for the ocr stage line.

Asked before the conversion, because a PDF the cache held no entry for is cached by
the time the line is written.
"""
suffix = Path(document).suffix.lower()
if suffix in (".md", ".markdown"):
return "read"
if suffix != ".pdf":
return "pandoc"
from in2lambda_agent.ocr import cached

return "cached" if cached(document, cache_dir) is not None else "mathpix"


def convert(
document: Path,
solutions: Optional[Path] = None,
Expand All @@ -473,47 +493,68 @@ def convert(
settings: Optional[Settings] = None,
lua: Optional[Path] = None,
name: str = "set",
on_stage: Optional[Callable[[str, str], None]] = None,
) -> Converted:
"""Route A, route B where a filter is given, reconcile, verify, write.

Route B reads the solutions document too, under its own role, and the two runs are
merged before the comparison. Where a filter run fails, the route A reply is the
result and `route_b_error` holds pandoc's message, so that one sheet of a folder does
not stop the other eight.

`on_stage`, where it is given, is called with a name and a message as each step
finishes - `ocr`, `route A`, `route B`, `fields`, `build` - so that a caller watching
a run shows each line as the step ends rather than the report at the end of it.
"""
settings = settings or load_settings()
backend = backend or choose_backend(settings)

def said(stage: str, message: str) -> None:
if on_stage is not None:
on_stage(stage, message)

read = [f"{Path(d).name}: {_read_as(d, cache_dir)}" for d in (document, solutions) if d is not None]
markdown, images = markdown_of(document, cache_dir, settings)
solutions_md = markdown_of(solutions, cache_dir, settings)[0] if solutions else None
said("ocr", "; ".join(read))
source = markdown + ("\n" + solutions_md if solutions_md else "")
reply, usage = direct(markdown, solutions_md, backend)
tokens = usage.usage.input_tokens + usage.usage.output_tokens
said("route A", f"{tokens} tokens")
counts, error = (0, 0, 0, 0), None
flags = [Flag(k, fields(reply)[k], "", "not a quote of the source") for k in not_verbatim(reply, source)]
if lua is not None:
if lua is None:
said("route B", "did not run: no filter")
else:
try:
other = run_filter(lua, document)
if solutions is not None:
other = merge(other, run_filter(lua, solutions, role="solutions"))
except (subprocess.CalledProcessError, json.JSONDecodeError) as problem:
stderr = getattr(problem, "stderr", None)
error = (stderr.decode("utf-8", "replace") if stderr else str(problem)).strip()
said("route B", f"failed: {error}")
else:
reconciled = reconcile(reply, other, source, backend)
reply, flags = reconciled.fields, reconciled.flags
counts = (
reconciled.agreed + reconciled.defaulted + reconciled.adjudicated,
reconciled.agreed, reconciled.defaulted, reconciled.adjudicated,
)
said("route B", "ran")
for k in stray_minus(reply):
if not any(f.field == k for f in flags):
flags.append(Flag(k, fields(reply)[k], "", STRAY_MINUS))
built = to_set(reply, name=name, directory=images)
return Converted(
set=built, zip_path=build(built, out_dir), flags=flags, reply=reply,
tokens=usage.usage.input_tokens + usage.usage.output_tokens,
result = Converted(
set=to_set(reply, name=name, directory=images), zip_path=None, flags=flags,
reply=reply, tokens=tokens,
fields=counts[0], agreed=counts[1], defaulted=counts[2], adjudicated=counts[3],
route_b_error=error,
)
said("fields", result.counted())
result.zip_path = build(result.set, out_dir)
said("build", str(result.zip_path))
return result


# --- route B: writing the filter -------------------------------------------------------
Expand Down
6 changes: 3 additions & 3 deletions in2lambda_agent/ui/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
"""The local web page for trying the agent by hand.

`in2lambda-agent ui` serves one page on 127.0.0.1: a source picker, the run's
options, the stage lines as they arrive, the questions of a waiting review, and
links to what the run wrote. It is a development harness, not a product
feature: there is no authentication, and one run at a time.
options, the stage lines as they arrive, the flagged fields the run ended with,
and links to what it wrote. It is a development harness, not a product feature:
there is no authentication, and one run at a time.

The page needs Starlette and uvicorn, which are the `ui` extra:
`poetry install --extras ui`. Nothing outside this package imports either, so
Expand Down
Loading
Loading