Skip to main content
Glama
J-X0

spanwatch

by J-X0
README.md
# spanwatch

Reconstructs the reading order of a laid-out document from block bounding boxes,
and serves it over the Model Context Protocol. It answers one question: given
text blocks with positions on a page, what order does a human read them in?

The ordering is computed geometrically, so it works with no model, no API key,
and no network. A language model can be plugged in to refine the order, but it is
strictly optional: if the model is missing, unconfigured, slow, or returns
something invalid, the deterministic result is used instead and every response
records which path ran.

## Why it exists

A plain "sort blocks by (y, x)" reads a two-column page as scrambled rows and
staples a full-width header onto the first column. spanwatch uses recursive
XY-cut: it splits a region at its widest clean whitespace gutter, choosing
horizontal gutters (which separate stacked bands like a header above a body)
before vertical ones (which separate columns). See `document/layout.py`.

## Architecture

Data flows one way: bytes in, ordered blocks out. The transports are thin; the
decisions live in the middle.

```
CLI (cli.py) ─┐                        ┌─ providers/stub.py   (tests, offline)
              ├─ service.run_reconstruct├─ providers/real.py   (Anthropic, opt)
MCP (server.py)┘   │                     └─ providers/base.py   (interface)
                   │
   io.parse_document ─ config.enforce_limits ─ extract.reconstruct ─ layout.reading_order
```

- `layout.py` is the load-bearing algorithm: recursive XY-cut, pure standard
  library, no model.
- `extract.py` runs the deterministic order first, then lets a provider refine
  it, validating the result and falling back on any failure.
- `providers/` is the only place a model is involved, behind one interface with
  a deterministic stub used by the whole test suite.
- `service.py` is the shared request path; `cli.py` and `server.py` are the two
  transports over it.

The design rationale for the contested calls is recorded in `docs/adr/`.

## Input schema

```json
{
  "pages": [
    {
      "number": 1,
      "width": 612,
      "height": 792,
      "blocks": [
        {"id": "b1", "text": "Title",   "bbox": [40, 30, 570, 70]},
        {"id": "b2", "text": "Left...",  "bbox": [40, 90, 300, 400]},
        {"id": "b3", "text": "Right...", "bbox": [320, 90, 570, 400]}
      ]
    }
  ]
}
```

`bbox` is `[x0, y0, x1, y1]` with the origin at the top-left and y increasing
downward. `width`/`height`/`kind` are optional.

## Library use

```python
from document import parse_document, reconstruct

pages = parse_document(doc)              # doc is the dict above
results = reconstruct(pages)             # no provider -> deterministic
for r in results:
    print(r.method, r.order)             # e.g. "deterministic" ['b1','b2','b3']
    print(r.text(pages[r.number - 1]))   # blocks joined in reading order
```

To let a model refine the order, pass a provider:

```python
from document.providers.real import AnthropicProvider
from document.providers.base import ProviderUnavailable

try:
    provider = AnthropicProvider()       # reads ANTHROPIC_API_KEY
except ProviderUnavailable:
    provider = None                      # fall back explicitly
results = reconstruct(pages, provider)
```

## Command line

```
make venv
make install

echo '{"pages":[...]}' | .venv/bin/spanwatch reconstruct --pretty
.venv/bin/spanwatch reconstruct doc.json --provider none
```

`reconstruct` reads a document from a FILE argument or stdin (`-`), writes the
result JSON to stdout, and logs one JSON object per line to stderr. Flags:

- `--pretty` indent the output JSON
- `--min-gap N` override the gutter threshold
- `--provider auto|none|anthropic` override provider selection

Exit codes: `0` success, `1` runtime failure (unreadable file, bad JSON, invalid
document, limit exceeded), `2` usage error.

## Running the MCP server

```
make serve            # or: .venv/bin/spanwatch serve
```

The server speaks MCP over stdio and exposes one tool,
`reconstruct_reading_order(document)`, returning per-page `order`, reconstructed
`text`, the `method` used (`deterministic`, `model`, or
`deterministic-fallback`), timing (`elapsed_ms`), and audit `notes`. Invalid
input comes back as `{"error": ...}` rather than crashing the server.

The provider is chosen at startup: the Anthropic provider if `ANTHROPIC_API_KEY`
is set and the `anthropic` package is installed (`pip install '.[real]'`),
otherwise none. With no key the server runs fully offline.

## Configuration

All settings come from the environment; CLI flags override them.

| Variable | Default | Meaning |
| --- | --- | --- |
| `SPANWATCH_PROVIDER` | `auto` | `auto`, `none`, or `anthropic` |
| `SPANWATCH_MODEL` | `claude-3-5-sonnet-latest` | model id for the real provider |
| `SPANWATCH_TIMEOUT` | `30` | model request timeout, seconds |
| `SPANWATCH_MIN_GAP` | (per page) | gutter threshold override |
| `SPANWATCH_MAX_PAGES` | `500` | reject documents with more pages |
| `SPANWATCH_MAX_BLOCKS_PER_PAGE` | `5000` | reject pages with more blocks |
| `SPANWATCH_LOG_LEVEL` | `INFO` | stderr log level |

Limits are enforced before any ordering work so oversized untrusted input fails
fast with a clear message instead of exhausting memory.

## Tests

```
make test             # or: .venv/bin/python -m pytest -q
```

The suite runs offline with no API key. It covers the XY-cut cases (single
column, two columns, header-over-columns, sub-threshold gutters), the fallback
contract (unavailable provider, invalid permutation, provider exception), input
validation, and that the model path is never taken without credentials.

Override the interpreter for any target with `make test PY=/path/to/python`.

## Limits

- Reading order assumes left-to-right, top-to-bottom scripts. RTL and vertical
  scripts are not handled.
- XY-cut needs a clean rectangular gutter to split. Overlapping boxes or complex
  magazine layouts with L-shaped text flow can defeat it; the leaf case then
  falls back to a (y, x) sort within the unsplittable region. TODO: a
  whitespace-density cut for regions XY-cut cannot separate cleanly.
- Blocks must already be detected upstream (from a PDF extractor or OCR).
  spanwatch orders blocks; it does not find them.