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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Parse Canadian swimming **On-Deck Evaluation** PDFs into a structured spreadsheet, using a local vision LLM. Hand-filled scanned forms or natively-generated PDFs, both supported.

> **Status:** under construction. **Fillable PDFs (e.g. `eval-gen` output, online-form exports) parse end-to-end today.** Scanned PDFs go through a local vision model; that path is being built. See [docs/design/0001-initial-design.md](docs/design/0001-initial-design.md) and [the open issues](https://github.com/swimblocks/deck-eval-parser/issues).
> **Status:** under construction. **Both paths now run end-to-end:** fillable PDFs (e.g. `eval-gen` output, online-form exports) via a fast deterministic path, and scanned PDFs via a local vision model (Qwen2.5-VL through Ollama). Interactive review / correction is next. See [docs/design/0001-initial-design.md](docs/design/0001-initial-design.md) and [the open issues](https://github.com/swimblocks/deck-eval-parser/issues).

## Quick start

Expand Down
40 changes: 27 additions & 13 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,38 @@
| Form-field fast path | [`src/form_extract.py`](../src/form_extract.py) | implemented |
| Merge + multi-meet detection | [`src/merge.py`](../src/merge.py) | implemented |
| Output (JSON + CSV + XLSX) | [`src/output.py`](../src/output.py) | implemented |
| End-to-end CLI (form-field path) | [`main.py`](../main.py) | implemented |
| End-to-end CLI (both paths) | [`main.py`](../main.py) | implemented |
| Ollama runtime + lifecycle | [`src/ollama_runtime.py`](../src/ollama_runtime.py) | implemented |
| GPU detection + tier picker | [`src/gpu_detect.py`](../src/gpu_detect.py) | implemented |
| Vision extraction | [`src/vision_extract.py`](../src/vision_extract.py) | implemented (not yet wired into the CLI — see #10) |
| Template detection | [`src/template_detect.py`](../src/template_detect.py) | implemented (not yet wired into the CLI — see #10) |
| Interactive review | — | pending — see [open issues](https://github.com/gavinbee/canswim-deck-eval-parser/issues) |
| Vision extraction | [`src/vision_extract.py`](../src/vision_extract.py) | implemented |
| Template detection | [`src/template_detect.py`](../src/template_detect.py) | implemented |
| Interactive review / edit loop | — | pending — see [open issues](https://github.com/swimblocks/deck-eval-parser/issues) |

## How a parse runs (form-field path)
## How a parse runs

Today only the form-field path is wired together:
`main.py` picks one of two paths based on whether the PDF has fillable widgets (`pdf_io.has_form_fields`).

### Form-field path (fillable PDFs — fast, no model)

1. **Open** the PDF via `pdf_io.open_pdf` (context-managed `fitz.Document`).
2. **Detect** whether it has fillable widgets via `pdf_io.has_form_fields`.
3. For each page, **read widgets** via `pdf_io.read_widgets`, which returns a `{widget_name: value}` dict with PyMuPDF's `[NNN]` disambiguator suffix stripped. See [`pdf-parsing.md`](pdf-parsing.md) for that and other gotchas.
4. Pass the widget dict plus the appropriate `Template` to `form_extract.extract_page`. It walks the template's `widget_field_map`, expanding `{i}` placeholders for per-row entries, and emits a `PageExtraction` (one `meet` dict, one `session` dict, and a list of `rows`, all of `FieldValue` with confidence 1.0). Trailing blank rows are dropped.
5. The result is a `list[PageExtraction]`.
6. **Merge** via `src.merge.merge(pages, ...)` to assemble the canonical `ParseResult`. Page 1's meet header is authoritative; later pages get `meet_match` set to `confirmed` (identical headers — eval-gen output), `carried` (blank), or — for headers that differ in non-trivial ways — go through the `same_meet_checker` callable (a Qwen2.5-7B call once the runtime lands; today the form-field path's fast paths handle everything `eval-gen` produces). A `different` verdict raises `MultiMeetError` (exit code 4). See [Multi-page reconciliation](#multi-page-reconciliation) below.
7. **Write** via `src.output.write_all(result, output_dir)` — JSON canonical, plus derived CSV and XLSX (one `evaluations` sheet). See [`output-schema.md`](output-schema.md).
2. For each page, **read widgets** via `pdf_io.read_widgets`, which returns a `{widget_name: value}` dict with PyMuPDF's `[NNN]` disambiguator suffix stripped. See [`pdf-parsing.md`](pdf-parsing.md).
3. Pass the widget dict plus the `Template` to `form_extract.extract_page` → a `PageExtraction` per page (confidence 1.0; trailing blank rows dropped).
4. Template defaults to `swim_ontario_v1` (or `--template`); no detection — we don't spin up a model just to classify a fillable form.
5. **Merge** then **write** (shared tail, below).

### Vision path (scanned / flat PDFs)

1. **Resolve the model**: `--vision-model`, else `gpu_detect` picks a tier from free VRAM (see [`models.md`](models.md)).
2. **Start Ollama** via the `OllamaDaemon` context manager — auto-starts the daemon if needed, ensures the model is pulled, stops the daemon on exit if we started it.
3. **Detect the template** from page 1 via `template_detect.detect_template` (unless `--template`). A recognized-but-stubbed province (e.g. Quebec) raises a helpful `NotImplementedError` (exit 2); a low-confidence / unknown result raises `TemplateDetectionError` (exit 2).
4. **Extract** each page with `vision_extract.extract_pdf` → `PageExtraction` list, cached to `<stem>.raw.json` (skip with `--no-cache`).
5. **Merge** with a `same_meet_checker` backed by the loaded vision model, so multi-page scans whose headers differ only by OCR noise get a real "same meet?" judgement instead of a spurious `MultiMeetError`.
6. **Write**.

### Shared tail (both paths)

- **Merge** via `src.merge.merge(pages, ...)` assembles the canonical `ParseResult`. Page 1's meet header is authoritative; later pages get `meet_match` = `confirmed` / `carried` / (model-judged) `confirmed`/`unknown`, or raise `MultiMeetError` (exit 4) on a `different` verdict. See [Multi-page reconciliation](#multi-page-reconciliation).
- **Write** via `src.output.write_all(result, output_dir)` — JSON canonical, plus derived CSV and XLSX (one `evaluations` sheet). See [`output-schema.md`](output-schema.md).

## Multi-page reconciliation

Expand All @@ -47,7 +61,7 @@ Today only the form-field path is wired together:

`meet_match.confidence` is folded into `row_confidence` so a shaky page-N reconciliation drags every row of that page into the low-confidence review.

The vision path (which will share the same `PageExtraction` output shape) is not yet wired in; see issue #8 onwards.
On the vision path the `same_meet_checker` is backed by the loaded vision model (`vision_extract.make_same_meet_checker`); on the form-field path no checker is passed, so the deterministic fast paths handle everything `eval-gen` produces and any genuine disagreement raises `MultiMeetError`.

## Key shapes

Expand Down
44 changes: 43 additions & 1 deletion docs/troubleshooting.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,45 @@
# Troubleshooting

> **Status:** placeholder. Will be populated as features land and real failure modes appear.
## Vision path: `GGML_ASSERT(a->ne[2] * 4 == b->ne[0]) failed` (HTTP 500)

**Symptom.** A scanned PDF errors during extraction with an Ollama 500 and a message containing `GGML_ASSERT(a->ne[2] * 4 == b->ne[0]) failed`. Template detection may succeed first, then the per-page extraction crashes.

**Cause.** This is a **known regression in Ollama ≥ 0.13.x** affecting Qwen2.5-VL (and other vision models) on CUDA. It is *not* a problem with your PDF or this tool — the projector matmul assertion fires inside Ollama's own runtime.

- [ollama#13630 — GGML_ASSERT crash with Qwen2.5-VL on CUDA (works on 0.12.x)](https://github.com/ollama/ollama/issues/13630)
- [ollama#14171 — same assert with glm-ocr](https://github.com/ollama/ollama/issues/14171)

**Fix / workaround.**
- **Downgrade Ollama to the 0.12.x line**, where Qwen2.5-VL works on CUDA. This is the most reliable fix today.
- Or track the issues above and move to a newer release once the regression is fixed upstream.

## Vision path: model runs 100% on CPU (very slow) on an 8 GB GPU

**Symptom.** `ollama ps` shows the model on `100% CPU` with a `SIZE` larger than your VRAM (e.g. `qwen2.5vl:3b … 10 GB … 100% CPU` on an 8 GB card). Extraction takes many minutes per page with high CPU and idle GPU.

**Cause.** A compute-graph memory-estimation change in **Ollama ≥ 0.13.4** over-estimates the memory a Qwen2.5-VL model needs, so it no longer fits the estimator's budget on an 8 GB GPU and Ollama falls back entirely to CPU.

- [ollama#13687 — qwen2.5vl:3b no longer runs on 8 GB GPUs since 0.13.4](https://github.com/ollama/ollama/issues/13687)

**Fix / workaround.**
- **Downgrade Ollama to 0.12.x** (same fix as the assert above — both regressions arrived together).
- Reducing the rasterized image size helps the footprint a little (the parser already caps the long edge to 1600 px; `pdf_io.rasterize_page(..., max_edge_px=…)` is tunable) but does not overcome the estimator regression on its own.
- A larger-VRAM GPU sidesteps the estimator fallback.

> **Why the tier picker still suggests 7B for 8 GB cards:** `gpu_detect` maps free VRAM to a model tier assuming a *working* Ollama. On a regressed Ollama even the 3B model won't GPU-offload on 8 GB. Once you're on a good Ollama version (0.12.x), the tier picker's mapping holds.

## Checking your Ollama version

```
ollama --version
```

If it reports 0.13.x or newer and you hit either symptom above on an NVIDIA card, the 0.12.x downgrade is the current remedy.

## Ollama not found / daemon won't start

See [installation.md](installation.md#troubleshooting-install-issues).

## Scanned PDF returns "no evaluation rows"

The vision model didn't find any official-rows it was confident about. Check the scan quality (very faint or skewed scans are hard), confirm the right template was detected (run with `-v`), and consider a higher-resolution scan. If the form is a province we don't support yet, template detection will say so.
49 changes: 25 additions & 24 deletions docs/usage.md
Original file line number Diff line number Diff line change
@@ -1,54 +1,55 @@
# Usage

> **Status:** v1, form-field path only. Vision extraction, interactive review, and the agentic edit loop land in later issues. This page is updated as flags become functional.
> **Status:** both extraction paths work. Interactive review / edit loop (`--interactive`, `--review-all`) is the next milestone.

## Quick start

```
python main.py path/to/eval.pdf
```

Outputs land in `./output/` as `<pdf-stem>.json`, `<pdf-stem>.csv`, and `<pdf-stem>.xlsx`. See [output-schema.md](output-schema.md) for the column contract.
- **Fillable PDFs** (eval-gen output, online-form exports) → fast deterministic form-field path. No model needed.
- **Scanned / flat PDFs** → local vision model (Qwen2.5-VL via Ollama). The daemon auto-starts and the model auto-pulls on first run.

Outputs land in `./output/` as `<pdf-stem>.json` (canonical), `<pdf-stem>.csv`, and `<pdf-stem>.xlsx`. See [output-schema.md](output-schema.md). On the vision path a `<pdf-stem>.raw.json` cache sidecar is also written.

## Flags

| Flag | Default | Status |
| Flag | Default | Notes |
|---|---|---|
| *(positional)* `pdf` | required | the input PDF |
| `--output-dir <DIR>` | `output` | output directory (created if missing) |
| `--template <ID>` | `swim_ontario_v1` | provincial template to parse against. Becomes "auto-detect" once template detection lands ([#9](https://github.com/swimblocks/deck-eval-parser/issues/9)) |
| `-v` / `-vv` | `WARNING` | verbosity. `-v` = `INFO`, `-vv` = `DEBUG` |
| `--vision-model <TAG>` | — | reserved for the vision path ([#8](https://github.com/swimblocks/deck-eval-parser/issues/8)) |
| `--edit-model <TAG>` | — | reserved for interactive edits ([#13](https://github.com/swimblocks/deck-eval-parser/issues/13)) |
| `--no-cache` | — | reserved for vision-path caching ([#8](https://github.com/swimblocks/deck-eval-parser/issues/8)) |
| `--no-auto-pull` | — | reserved for Ollama runtime ([#6](https://github.com/swimblocks/deck-eval-parser/issues/6)) |
| `--interactive` | — | reserved for interactive review ([#12](https://github.com/swimblocks/deck-eval-parser/issues/12)) |
| `--review-all` | — | reserved for walk-every-eval mode ([#14](https://github.com/swimblocks/deck-eval-parser/issues/14)) |
| `--low-confidence-threshold <FLOAT>` | — | reserved for interactive review ([#12](https://github.com/swimblocks/deck-eval-parser/issues/12)) |
| `--template <ID>` | auto | force a provincial template. Default: auto-detect from page 1 on the vision path; `swim_ontario_v1` on the form-field path. Choices are implemented templates only. |
| `--vision-model <TAG>` | auto | Ollama vision model tag. Default: auto-picked from detected GPU VRAM (see [models.md](models.md)). Vision path only. |
| `--no-cache` | off | re-invoke the vision model even if a `<stem>.raw.json` cache exists. Vision path only. |
| `--no-auto-pull` | off | don't auto-pull missing Ollama models; error with the manual `ollama pull` command instead. Vision path only. |
| `-v` / `-vv` | `WARNING` | verbosity. `-v` = `INFO` (shows the picked model/tier, template, daemon lifecycle), `-vv` = `DEBUG`. |

Reserved for the next milestone (interactive review): `--interactive`, `--review-all`, `--edit-model`, `--low-confidence-threshold`.

## Exit codes

| Code | Meaning |
|---|---|
| `0` | success |
| `1` | extraction failure — PDF has no fillable widgets (scanned), and the vision path isn't yet implemented |
| `2` | validation failure — PDF doesn't match the chosen template, or no recognisable evaluation rows |
| `1` | extraction failure — Ollama not installed / unreachable, model missing under `--no-auto-pull`, or the vision model returned unparseable output |
| `2` | validation failure — PDF missing, template can't be identified (or is a not-yet-supported province), or no recognisable evaluation rows |
| `3` | reserved for `--interactive` user abort |
| `4` | `MultiMeetError` — pages in the same PDF reference more than one meet (see [architecture.md](architecture.md#multi-page-reconciliation)) |
| `4` | `MultiMeetError` — pages reference more than one meet (see [architecture.md](architecture.md#multi-page-reconciliation)) |

## Examples

```
# Default — produce JSON + CSV + XLSX in ./output/
# Fillable PDF — fast path, no model
python main.py session_3_evals.pdf

# Custom output directory
python main.py session_3_evals.pdf --output-dir ~/Desktop/evals
# Scanned PDF — auto-detect template, auto-pick model for your GPU
python main.py data/scan.pdf -v

# Verbose logging for debugging
python main.py session_3_evals.pdf -vv
```
# Force a specific model and template, skip the cache
python main.py data/scan.pdf --vision-model qwen2.5vl:32b \
--template swim_ontario_v1 --no-cache

## What's not in v1

Scanned PDFs route through a local vision model (Qwen2.5-VL via Ollama). That path is the next several issues. Until then, scanned inputs exit with code 1 and a clear message.
# Air-gapped / pre-pulled: fail loudly instead of pulling
python main.py data/scan.pdf --no-auto-pull
```
Loading
Loading