feat: invoice-to-csv 0.4.0 (code-only extractor, validation, grounding, optional second reading) #6
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/invoice-to-csv"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
invoice-to-csv, first slice: PDF invoices in, validated CSV out. No model, no network, no download by default; about 1 ms per invoice.rules/): label vocabularies in nl/en/de, a line-table reader keyed on its header words, locale-aware amounts and dates (decimal comma vs point detected per document). A layout it does not recognise, a missing value or an ambiguity goes toneeds_review/with the reason. Nothing is guessed.--verify-model, extra[model]): a small GLiNER2.5 model with its vocabulary trimmed to nl/de/en (243 MB) reads supplier, invoice number and dates independently; a disagreement sends the invoice to review.tools/build_model.pybuilds it.--distractors), committed development / held-out / clutter sets, an eval with a silent-error metric, CI floor tests.Measured, and what it does not show
StefanStefan/german-invoice): 0 accepted (all refused for layout). It refuses what it cannot read.Not done
--watch, an accounting export format, a Docker image, routing through redact-proxy.Checked
320 hermetic tests, ruff, import-linter (4 contracts), pre-commit. The real model was run manually on all sets.
🤖 Generated with Claude Code
🤖 Auto-review status ·
jonah/safe-ai-toolsPR #6📋 Review posted — fixes required, awaiting human action.
Last updated: 2026-10-06T19:42:21.992Z
[STATUS: FIXES_REQUIRED]
Verified first
Ran locally (had to
pip install pdfplumber fpdf2+ editable-install the package into.venv;uvis not on this box):ruff check .+ruff format --check .→ cleanlint-imports→ 4/4 contracts kept (including the two newinvoice_to_csvones)pytest -m "not integration"→ 320 passed (217 new for this package), 1 skipped (the model integration test), 2 deselectednoqa/type: ignore/bareexcept/TODO anywhere in the diff; CHANGELOG updated alongside theVERSIONbump (satisfies the CI policy job)Must fix
ingest.py:24— corrupt-but-openable PDFs crash the entire run, losing all output.except (PdfminerException, OSError)misses pdfplumber'sMalformedPDFException(a sibling class, not a subclass — confirmed via MRO) and internalTypeErrors. Fuzzed 70 corruptions of a real invoice PDF (truncations + byte flips): 7 escapedIngestError(MalformedPDFException,TypeError: 'NoneType' object is not iterable).pipeline.process(pipeline.py:31) only catches(IngestError, ExtractError), soprocess_allaborts andrun()raises beforewrite_results— zero CSVs from the batch, on untrusted input the tool explicitly claims to handle ("unreadable PDF → review"). Root-cause fix is one place:read_textis the single seam — wrap broadly there (except Exception), keeping the existing message that already carriestype(exc).__name__, so the failure stays diagnosable viareason.txtinstead of destroying the run.output.py:84—_file_for_reviewassumes the rejected path is a readable regular file; it isn't always. Reproduced two ways, both abortingrun()after processing with no output written:ghost.pdfin the input folder →FileNotFoundError(ingest correctly converts this toIngestError→Rejected, then the copy reintroduces the crash)weird.pdf→IsADirectoryError(folder.glob("*.pdf")at__main__.py:35matches directories)Lazy fix: copy only if
rejected.path.is_file(), always write the.reason.txt. One line.Should fix (low)
output.py:89-93—_clear_stale_reviewonly prunes review copies of now-accepted invoices. PDFs deleted from the input folder (or a different folder run into the same--out) linger inneeds_review/forever, so a human works a stale queue that contradicts the docstring's "rewrite … fromoutcomes". Rebuild the folder from the current rejected set.eval/score.py:82— emptygold.jsonl→ZeroDivisionErrortraceback (/ totalwithtotal == 0) instead of an actionable message (§1.XVII).to_markdownalready guards with_pct;accuracydoesn't.Info (deliberate, no action needed)
output.py:34-40_cellprefixes'to any value starting with-, so a genuine negative amount (credit note — "creditnota" is in_TITLES) lands in the CSV as text, not a number. Standard OWASP CSV-injection trade-off; just know it if anything downstream expects numerics..venvhere predated this branch (invoice_to_csv,pdfplumber,fpdfwere missing); CI'suv sync --all-packages --devcovers it. The GLiNER path is untested beyond the stub test (integration test skips withoutINVOICE_MODEL_DIR).Review findings addressed in
9e35410:Checked: 326 hermetic tests, ruff, import-linter; 70 truncated/byte-flipped PDFs plus a folder and a dangling symlink run to completion (1 file survived intact and was accepted, 71 in review). The negative-amount CSV trade-off is noted in the CHANGELOG.
🤖 Generated with Claude Code
🤖 Auto-review status ·
jonah/safe-ai-toolsPR #6📋 Review posted — fixes required, awaiting human action.
Last updated: 2026-10-06T20:18:13.390Z
[STATUS: FIXES_REQUIRED]
Verified first
ruff check .+ruff format --check .→ clean;lint-imports→ 4/4 contracts kept;pytest -m "not integration"→ 326 passed (6 new tests from the fix commit), 2 deselected.noqa/type: ignore/TODO in the diff; the only bare-ish catch is the deliberate, commentedexcept Exceptionatingest.py:23.pipeline.process→ 0 escapes (was 7/70); dangling-symlink and directoryx.pdfrepros now produce only.reason.txt(no crash);needs_review/rebuild and the empty-goldparser.errorboth land with tests. CHANGELOG documents each fix.Must fix
pipeline.py:33—validate/grounding/verifiers run outside thetry; two proven crashes abort the whole batch beforewrite_results, zero CSVs out. The ingest seam was hardened, butexcept (IngestError, ExtractError)atpipeline.py:31only covers lines 29–30;_problems(line 33 → 19) runs unguarded. End-to-end repros on PDFs that extract successfully:formats.py:32—number_readingsbuildsDecimal(token…)for tokens mixing,and.with ≥2 of the decimal kind:numbers_inraisesdecimal.InvalidOperationon"1,2.3.4","1,234.567.89","192,168.1.1".ground.py:88runs over the entire PDF text for every invoice that reaches validation, so one version-string/IP/token anywhere in the document crashesrun()(verified: no output dir created).validate.py:80/:103—.quantize(_CENT, ROUND_HALF_UP)raisesInvalidOperationwhen an amount exceeds the 28-digitDecimalcontext; a table row with a 30-digit figure reproduced the same batch-killing traceback throughrun().--verify-model:gliner.py:39/42index["entities"]/["text"]directly, and any model runtime error on one document escapes the same unguarded call (untested — integration test skips withoutINVOICE_MODEL_DIR).process, catch unexpected exceptions around_problemsand returnRejected(path, (f"{type(exc).__name__} …",))— same pattern as ingest, stays diagnosable viareason.txt, covers verifiers too. For the grounding case also fixnumber_readingssemantically: a token it cannot convert is not a number — skip it, so such invoices are accepted rather than all landing in review.Should fix (low)
output.py:86—shutil.copy2still re-raises after theis_file()guard. Reproduced: a stat-able but unreadable PDF (mode000, e.g. dropped into a shared inbox by another user) →read_textcorrectly rejects it, thencopy2raisesPermissionError→ the run dies mid-review-write after the CSVs are written, remaining.reason.txtentries lost, raw traceback. Make the copy best-effort (try/except OSError→ note the copy failure in the reason file).eval/__main__.py:34-37— onlyFileNotFoundErroris handled; a malformedgold.jsonl(blank line, bad JSON, invalid invoice record) still surfaces a rawjson/pydantic traceback instead ofparser.error— same §1.XVII class as the empty-gold fix added right below it. Add the parse/validation errors to the existingexcept.Info
__main__.py:35glob("*.pdf")is case-sensitive: a folder containing onlyINVOICE.PDFreports "no PDF files" (arguably fine, noting for completeness).score()documents its non-empty precondition with the CLI guarding it.View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.Merge
Merge the changes and update on Forgejo.Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.