Metadata-Version: 2.5
Name: fintom8
Version: 0.1.45
Summary: LiteLLM connector for Gemini, Vertex AI, OpenAI, and Azure — chat, stream, and document extract.
Project-URL: Homepage, https://github.com/NikolaienkoIgor/f8_templates
Project-URL: Documentation, https://github.com/NikolaienkoIgor/f8_templates/tree/main/fintom8
Author: Igor Nikolaienko
Author-email: Harsh Bansal <harshhb6@gmail.com>
License: MIT
Keywords: azure,gemini,litellm,llm,openai,vertex
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Requires-Python: >=3.14
Requires-Dist: langgraph>=0.2.74
Requires-Dist: langsmith>=0.11.0
Requires-Dist: litellm[google]<2.0.0,>=1.94.0
Requires-Dist: lxml>=6.1.0
Requires-Dist: openpyxl>=3.1.0
Requires-Dist: pydantic>=2.4.0
Requires-Dist: python-dotenv>=1.0.0
Requires-Dist: tenacity>=8.2.0
Provides-Extra: dev
Requires-Dist: build>=1.2.0; extra == 'dev'
Requires-Dist: lxml>=6.1.0; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
Requires-Dist: pytest>=8.0.0; extra == 'dev'
Requires-Dist: twine>=5.0.0; extra == 'dev'
Provides-Extra: pdf
Requires-Dist: fonttools>=4.0.0; extra == 'pdf'
Requires-Dist: pikepdf>=9.0.0; extra == 'pdf'
Requires-Dist: pillow>=10.0.0; extra == 'pdf'
Requires-Dist: pypdf>=5.0.0; extra == 'pdf'
Requires-Dist: pypdfium2>=4.0.0; extra == 'pdf'
Requires-Dist: reportlab>=4.0.0; extra == 'pdf'
Provides-Extra: schematron
Requires-Dist: lxml>=6.1.0; extra == 'schematron'
Requires-Dist: saxonche; extra == 'schematron'
Provides-Extra: xsd
Requires-Dist: lxml>=6.1.0; extra == 'xsd'
Description-Content-Type: text/markdown

# fintom8

LiteLLM connector for **Gemini / Vertex AI / OpenAI / Azure**. Chat, stream, and document extract. Students install with pip and call a few methods — keys stay in `.env`.

Requires **Python 3.14+**.

### Install from Git (recommended for private use)

GitHub Packages Python upload is currently unreliable (SSL issues). Install directly from this repo instead:

```bash
# HTTPS (use a PAT with repo read access if the repo is private)
pip install "git+https://github.com/NikolaienkoIgor/f8_templates.git#subdirectory=fintom8"

# Pin a tag / commit
pip install "git+https://github.com/NikolaienkoIgor/f8_templates.git@fintom8-v0.1.45#subdirectory=fintom8"

# SSH (no token in the URL if your SSH key is set up)
pip install "git+ssh://git@github.com/NikolaienkoIgor/f8_templates.git#subdirectory=fintom8"
```

Private HTTPS with an explicit token:

```bash
pip install "git+https://<GITHUB_USERNAME>:<GITHUB_PAT>@github.com/NikolaienkoIgor/f8_templates.git#subdirectory=fintom8"
```

### Other install options

```bash
# Public PyPI (when published)
pip install fintom8

# Local editable install from this checkout
pip install -e ./fintom8
# or: pip install -e "./fintom8[dev]"
```

```python
import asyncio
from fintom8 import LLM

async def main():
    llm = LLM()  # reads .env / environment
    print((await llm.achat("Summarize this invoice")).text)

asyncio.run(main())
```

## Configuration

Resolution order: **constructor kwargs / `LLMConfig` > environment > defaults**.

Copy [`.env.example`](./.env.example) to `.env` in your project (never commit it).

| Param | Env | Default | When needed |
|-------|-----|---------|-------------|
| `model` | `LLM_MODEL` | `gemini/gemini-3.5-flash` | always |
| `temperature` | `LLM_TEMPERATURE` | unset (Gemini 3+), `1.0` (older Gemini/Vertex), else `0.0` | optional; omitted for Gemini 3+ (deprecated by Google) |
| `num_retries` | — | `3` | optional |
| `api_key` | `GEMINI_API_KEY` / `OPENAI_API_KEY` / `AZURE_API_KEY` (from model prefix) | unset | Gemini / OpenAI / Azure (`azure/` → required) |
| `api_base` | `AZURE_API_BASE` / `OPENAI_API_BASE` | unset | Azure (`azure/` → required) |
| `api_version` | `AZURE_API_VERSION` | unset | Azure (`azure/` → required) |
| `vertex_project` | `VERTEXAI_PROJECT` | unset | Vertex |
| `vertex_location` | `VERTEXAI_LOCATION` | `eu` | Vertex |

For `azure/<deployment>`, missing `api_key`, `api_base`, or `api_version` raises `Fintom8Error` (set via constructor or `AZURE_*` env vars).

```python
from fintom8 import LLM, LLMConfig

llm = LLM()  # env defaults
llm = LLM(model="gpt-4o", api_key="sk-...", temperature=0)
llm = LLM(LLMConfig(
    model="azure/my-deploy",
    api_key="...",
    api_base="https://....openai.azure.com",
    api_version="2024-10-21",
))
```

### Switch provider

| `LLM_MODEL` | Env |
|-------------|-----|
| `gemini/gemini-3.5-flash` | `GEMINI_API_KEY` |
| `vertex_ai/gemini-3.5-flash` | `VERTEXAI_PROJECT` + `VERTEXAI_LOCATION` + ADC (`gcloud auth application-default login`) |
| `gpt-4o` | `OPENAI_API_KEY` |
| `azure/<deployment>` | `AZURE_API_KEY` + `AZURE_API_BASE` + `AZURE_API_VERSION` |

## Contributors

- Igor Nikolaienko
- [Harsh Bansal](https://github.com/harshhb6)

## Usage

```python
import asyncio
from fintom8 import LLM, facturx_to_bt_json, zugferd_to_bt_json

async def main():
    llm = LLM()

    resp = await llm.achat("Hello")
    print(resp.text, resp.usage)

    # Structured output — file only; detect format → LiteLLM SO → cleanse dates
    invoice_rf = {
        "type": "json_schema",
        "json_schema": {
            "name": "Invoice",
            "strict": True,
            "schema": {
                "type": "object",
                "properties": {
                    "total": {"type": "number"},
                    "vendor": {"type": "string"},
                    "invoiceDate": {"type": ["date", "null"]},
                },
                "required": ["total", "vendor", "invoiceDate"],
                "additionalProperties": False,
            },
        },
    }
    data = await llm.astructured(
        "invoice.pdf",  # also: .png/.jpg, .txt/.csv/.xml/.xlsx, or bytes
        structuredOutput=invoice_rf,
        dateFormat="DD.MM.YYYY",
    )
    # {"total": 42.5, "vendor": "Acme", "invoiceDate": "08.08.2026"}

    # Invoice / EN16931 / chem ping-pong (keyword args)
    report = await llm.agenerate_and_validate(name="invoice", source="invoice.pdf")
    # {"correct": true, "name": "invoice", "format": "json", "artifact": {…}, …}

    en16931 = await llm.agenerate_and_validate(
        name="en16931", source="invoice.pdf", invoice_format="ubl"
    )
    # en16931["artifact"] is the last UBL XML string; requires fintom8[schematron]

    chem = await llm.agenerate_and_validate(name="chemical_composition", source="cert.pdf")
    # Prefer a path for source; pass filePath= only when source is raw bytes

    # Document → UBL / ZUGFeRD XML (LLM generate only; no Schematron loop)
    ubl_xml = await llm.aconvert("invoice.pdf", invoice_format="ubl")
    zugferd_xml = await llm.aconvert("invoice.csv", invoice_format="zugferd")
    # also: .json / .xml / images / .xlsx — or bytes with mime= / filePath=

    # ZUGFeRD / Factur-X CII XML → EN 16931 BT JSON (CPU; sync is fine)
    bt = zugferd_to_bt_json("invoice.xml")  # or XML text / bytes
    # bt["BT-1"] == {"description": "Invoice number", "value": "INV-1"}

    # Factur-X CII or UBL → factur-x field ids (no XSD check)
    fx = facturx_to_bt_json("invoice.xml")
    # fx["BT-1"] == {"description": "Invoice Number", "value": "INV-1"}

    # aprocess() is an alias of astructured()
    data = await llm.aprocess(
        "invoice.pdf",
        structuredOutput=invoice_rf,
        dateFormat="DD.MM.YYYY",
        instructions="Extract invoice vendor and total.",
    )

    async for chunk in llm.astream([{"role": "user", "content": "Write a haiku"}]):
        print(chunk, end="", flush=True)

    resp = await llm.aextract("invoice.pdf", response_format=invoice_rf)

asyncio.run(main())
```

LLM / network APIs are **async-only**: `achat`, `astream`, `aextract`, `astructured`, `aprocess`, `aconvert`, `agenerate_and_validate`, `acorrect_and_validate`, `acorrect_facturx_from_pdf`, `acompare`, `adetect_pii`, `aanonymize`.

CPU helpers (validate / PDF / XML mapping) stay **sync**, with async `a*` twins via `asyncio.to_thread` for FastAPI: `validate`/`avalidate`, `create_pdfa3`/`acreate_pdfa3`, `extract_xml`/`aextract_xml`, `mask_pdf`/`amask_pdf`, `facturx_to_bt_json`/`afacturx_to_bt_json`, `zugferd_to_bt_json`/`azugferd_to_bt_json`, …

Optional helpers: `detect_format`, `fields_to_schema`, `compile_fields`, `prepare_response_format`, `apply_cleanse`, `json_schema_response_format`, `structured_output`, `enforce_strict`, `inline_refs`, `acompare`.

### Compare two sources

LLM-based dual-source comparison with built-in profiles or a custom schema:

```python
import asyncio
from fintom8 import LLM

async def main():
    llm = LLM()

    # PDF/image/document vs invoice XML (payment-critical mismatches)
    report = await llm.acompare("invoice.pdf", "invoice.xml", profile="document_xml")
    # {"matches": true, "total_mismatches": 0, "mismatches": []}

    # Mapping document vs reference rules text (Peppol gap analysis step 1)
    findings = await llm.acompare(
        "mapping.html",
        "PEPPOL-EN16931-UBL.sch",
        profile="mapping_reference",
        max_findings=5,
        model="gemini/gemini-3.1-pro-preview",
    )

    # Two invoice XMLs — structural diffs + calculation fixes (e.g. BR-CO-15)
    xml_report = await llm.acompare(
        "invoice_original.xml",
        "invoice_corrected.xml",
        profile="xml-comparison",
        max_findings=10,
    )

    # Custom comparison
    custom = await llm.acompare(
        doc_a,
        doc_b,
        profile="custom",
        instructions="Compare these two contracts for clause differences.",
        structuredOutput={"differences": {"type": "array", "items": {"type": "string"}}},
    )

asyncio.run(main())
```

Register additional profiles with `fintom8.comparison.register_compare_profile()`. See [`examples/compare_document_xml.py`](examples/compare_document_xml.py) and [`examples/compare_xml.py`](examples/compare_xml.py).

### Bundled templates

```python
import asyncio
from fintom8 import LLM
from fintom8.templates import invoice
# or: from fintom8 import templates; templates.invoice
# or: from fintom8 import use_template; use_template("invoice")

async def main():
    llm = LLM()
    data = await llm.astructured(
        "invoice.pdf",
        structuredOutput=invoice["structuredOutput"],
        systemPrompt=invoice["systemPrompt"],
        dateFormat=invoice.get("dateFormat", "YYYY-MM-DD"),
    )

asyncio.run(main())
```

`list_templates()` lists packaged names (`invoice`, `chemical_composition`, `recipient_statement`). Pass a path or dict to `use_template` for custom templates.

### Validation

One engine, one envelope. Document types differ only by the **pack** registered for that name. ERP check is a second step on the same envelope — not a different API.

```python
from fintom8 import validate, erp_check

report = validate("invoice", payload)
report = validate("chemical_composition", payload)
report = validate("recipient_statement", payload)

# EN16931 Schematron on UBL or CII XML (format is detected; requires fintom8[schematron])
en16931 = validate("en16931", {"xml": ubl_or_cii_xml})
# or: validate("en16931", {"xml_path": "invoice.xml"})

# Factur-X EN 16931 XSD on CII XML (ZUGFeRD Comfort successor; requires fintom8[xsd])
facturx = validate("facturx_en16931", {"xml": cii_xml})
# UBL 2.1 XSD on Invoice or CreditNote XML (same optional extra)
ubl = validate("ubl_2.1", {"xml": ubl_xml})

# ERP check is named + injected. qc55 is SAP vs chemical coils only.
qc55 = erp_check("qc55", chem_payload, reference=qc55_rows)  # bundled qc55.csv if omitted
```

Every call returns:

```json
{
  "correct": false,
  "name": "invoice",
  "format": "json",
  "errors": [{"path": "lineItems[0].totalPriceWithTax", "message": "...", "code": "FORMULA"}],
  "warnings": [],
  "artifact": {},
  "units": null,
  "_debug": null
}
```

`artifact` is the document (invoice JSON, chemical extraction, recipient JSON, or EN16931 XML string). `format` is `json`, `ubl`, or `cii`. Chemical and QC55 put per-coil reports in `units` (including a single coil). Invoice rewritten totals live in `artifact` (the caller's payload is not mutated). QC55 match rows live in `units[coil_id].artifact`. EN16931 `format` is `ubl` or `cii`; Schematron rule ids (e.g. `BR-CO-15`) are in `errors[].code` and XPath in `errors[].path`. `_debug` is `{attempts, exhausted, raw}` when `include_debug=True`. `agenerate_and_validate(..., include_debug=True)` also adds `_debug.history`: one `{attempt, correct, errors, warnings}` per ping-pong iteration, so a later pass still shows why earlier attempts failed. Top-level `errors` is only the last attempt.

Install Schematron support with:

```bash
pip install "fintom8[schematron]"
```

PDF/A-3 (embed invoice XML into a PDF) and PDF text masking are available via:

```bash
pip install "fintom8[pdf]"
```

```python
from pathlib import Path
from fintom8 import create_pdfa3, extract_xml, get_facturx_xml_from_pdf, mask_pdf

pdfa3_bytes = create_pdfa3("invoice.pdf", "invoice.xml")
# Re-embedding replaces any prior *.xml attachments (non-XML attachments kept).
xml = extract_xml(pdfa3_bytes)  # or extract_xml("invoice_zugferd.pdf")
# factur-x-style extract: (filename, xml_bytes) or (False, False)
name, xml_bytes = get_facturx_xml_from_pdf("invoice_zugferd.pdf")

masked = mask_pdf(
    "invoice.pdf",
    {"Acme GmbH": "Demo Corp", "DE123456789": "DE000000000"},
)
Path("masked.pdf").write_bytes(masked.pdf_bytes)
```

`mask_pdf` finds each original string on the page, paints a cover box with the replacement, and by default raster-flattens so the original text layer is destroyed. Hit counts are in `masked.applied_counts`.

To **detect** PII with the LLM and then mask (anonymizer endpoint), use `detect_pii` plus `mask_pdf`, or `anonymize_pdf` for both steps:

```python
import asyncio
from pathlib import Path
from fintom8 import LLM, mask_pdf

async def main():
    llm = LLM()

    # LLM call only — same mapping the anonymizer endpoint feeds into mask_pdf
    items = await llm.adetect_pii("invoice.pdf")
    mapping = {item.original: item.replacement for item in items}
    masked = mask_pdf("invoice.pdf", mapping)

    # Or one shot: detect + mask
    result = await llm.aanonymize("invoice.pdf")
    Path("anonymized.pdf").write_bytes(result.pdf_bytes)
    # result.items, result.mapping, result.applied_counts

asyncio.run(main())
```

See [`examples/anonymize_pdf.py`](examples/anonymize_pdf.py).

Bundled EN16931 XSLT files are licensed under EUPL 1.2 (CEN). Default `pip install fintom8` does not require Saxon or pikepdf.

Chemical composition **document** validation does not call QC55; sequence them explicitly:

```python
from fintom8 import validate, erp_check

chem = validate("chemical_composition", extracted)
qc55 = erp_check("qc55", extracted)  # or erp_check("qc55", extracted, reference=live_rows)
```

Optional aliases: `validate_invoice`, `validate_recipient_statement`, `validate_en16931`, `validate_facturx_en16931`, `validate_ubl`, `erp_check_qc55` — same envelope as the named calls above.

### Generate + validate (ping-pong)

One standard wrapper. ``name`` selects SO (JSON schema) or NON-SO (XML); ``source`` is the document path or bytes. Same Validation Wrapper loop: validate → on error, correction prompt + regenerate → until `correct` or max attempts. For ``en16931``, the validate step runs Schematron and the matching XSD together: Factur-X EN 16931 when ``invoice_format`` is ``"zugferd"`` (or ``cii`` / ``factur-x``), and UBL 2.1 when it is ``"ubl"``. The corrector receives both error lists and the XML once.

```python
import asyncio
from fintom8 import LLM

async def main():
    llm = LLM()
    report = await llm.agenerate_and_validate(name="invoice", source="invoice.pdf", include_debug=True)

    report = await llm.agenerate_and_validate(
        name="en16931", source="invoice.pdf", invoice_format="ubl", include_debug=True
    )
    # report["artifact"], report["_debug"]["attempts"]

    report = await llm.agenerate_and_validate(name="chemical_composition", source="cert.pdf")

asyncio.run(main())
```

Packs stay pure (no LLM/retry inside `validation/`). Pass ``filePath=`` / ``mime=`` only when ``source`` is raw bytes.

### Correct Factur-X PDF (extract → validate → fix → PDF/A-3)

For an existing Factur-X / ZUGFeRD PDF: extract the embedded XML, run the EN16931 cleanse / Schematron (+ XSD) / LLM correction loop on the XML only (PDF is not sent to the LLM), then `create_pdfa3` (any prior `*.xml` attachments are removed and replaced). Attempt 1 uses the extracted XML (no generate-from-scratch).

```python
import asyncio
from pathlib import Path
from fintom8 import LLM

async def main():
    llm = LLM()
    report = await llm.acorrect_facturx_from_pdf("invoice_facturx.pdf", include_debug=True)
    corrected_xml = report["artifact"]       # str
    Path("corrected.pdf").write_bytes(report["pdfa3"])  # PDF/A-3 bytes

asyncio.run(main())
```

See [`examples/correct_facturx_from_pdf.py`](./examples/correct_facturx_from_pdf.py).

### Convert document → UBL / ZUGFeRD XML (generate only)

Same idea as the platform invoice-agent generate step without the Schematron loop. For generate + validate + fix, use `agenerate_and_validate(name="en16931", source=…)` instead.

```python
import asyncio
from fintom8 import LLM, aconvert_to_xml

async def main():
    llm = LLM()
    ubl = await llm.aconvert("invoice.pdf", invoice_format="ubl")
    cii = await llm.aconvert("invoice.json", invoice_format="zugferd")  # aliases: cii, factur-x
    # or: await aconvert_to_xml(llm, "invoice.pdf", invoice_format="ubl")

asyncio.run(main())
```

See [`examples/convert_to_xml.py`](./examples/convert_to_xml.py), [`examples/validate_en16931.py`](./examples/validate_en16931.py).

See [`examples/invoice_extraction_fintom8.py`](./examples/invoice_extraction_fintom8.py), [`examples/generate_and_validate.py`](./examples/generate_and_validate.py), [`examples/validate_invoice.py`](./examples/validate_invoice.py), [`examples/validate_chemical_composition.py`](./examples/validate_chemical_composition.py), [`examples/validate_recipient_statement.py`](./examples/validate_recipient_statement.py), [`examples/validate_en16931.py`](./examples/validate_en16931.py), [`examples/erp_check_qc55.py`](./examples/erp_check_qc55.py).

Also exported: `Invoice`, `LineItem`, `ValidationReport`, `FieldError`, `validation_payload_from_llm`. Packs are pure (dict in, report out) — no LLM, HTTP, or retry loops.

Failures raise `Fintom8Error`.

If you see an authentication error (for example missing `GEMINI_API_KEY`, `OPENAI_API_KEY`, or Vertex setup), that means package import and retries are working; configure credentials for the selected `LLM_MODEL`.

See [`examples/chat.py`](./examples/chat.py) and [`examples/extract.py`](./examples/extract.py).

## Publish (maintainers)

1. Install dev extras and run tests:

   ```bash
   cd fintom8
   pip install -e ".[dev]"
   pytest
   python -c "from fintom8 import LLM"
   ```

2. Build:

   ```bash
   python -m build
   ```

3. Upload to TestPyPI first, then PyPI:

   ```bash
   python -m twine upload --repository testpypi dist/*
   python -m twine upload dist/*
   ```

4. Tag for CI Trusted Publishing (OIDC). Create the PyPI project once and add a GitHub environment `pypi` with Trusted Publisher pointing at `.github/workflows/publish-fintom8.yml`. Then:

   ```bash
   git tag fintom8-v0.1.45
   git push origin fintom8-v0.1.45
   ```
