Знакът на КАГАМИ КАГАМИ
kagami.bg/academy · lesson · machine-readable viewUPDATED 2026-10-03
IDENTITY
module
GX10-04-131 · Invoice pipeline: PDF to checked fields and a draft entry
series
GX10 (local AI server class: NVIDIA GB10, e.g. ASUS Ascent GX10 / DGX Spark)
level
Advanced
duration
2 h
prerequisites
Running OCR service from lesson 04-75 (localhost:8000); Ollama with a model (example llama3.3:70b); PostgreSQL 18; Python 3; invented invoices only; an accountant-supplied category-to-account table
trust_label
UPDATED 2026-10-03 (checked against the TrOCR model card, PyMuPDF, Ollama structured-outputs docs, Ollama library pages, NVIDIA NIM Image OCR docs via lesson 04-75, NRA invoicing page) · NOT TESTED on a GB10 machine; only pure-Python parts (PDF page classification, amount parsing, checks, draft entry) were run on an ordinary computer with invented data
versions
PyMuPDF 1.26.7 · pydantic 2.12.5 · ollama (Python) 0.6.3 · nvcr.io/nim/nvidia/nemotron-ocr-v2:2.0 · PostgreSQL 18 · llama3.3:70b (about 43 GB)
language
human view: bg · english edition: /en/academy/gx10/ (same file name)
previous / next
04-130_AI_Accounting_Assistant.html / 04-132_Bank_Statement_Reconciliation.html
PURPOSE

Build a local invoice intake pipeline: classify each PDF page as embedded text or image, OCR only image pages with the Nemotron OCR NIM, extract fields with an Ollama model constrained to a Pydantic JSON schema, verify amounts and VAT with plain code, build a draft journal entry from an accountant-supplied account table, and queue everything in PostgreSQL for human approval. The model only reads; it never calculates, decides or posts.

KEY CONCEPTS
COMMANDS / PATHS
CHECKLIST
NEXT MODULE

04-132 · Bank statement reconciliation (04-132_Bank_Statement_Reconciliation.html) · related: 04-127 (company ID checksum, VIES), 04-75 (Nemotron OCR) · series index: kagami.bg/en/academy/gx10/ · offer: Quick experiment (kagami.bg/stalbata/)

SOURCES
TAGS
gx10nvidia-gb10invoiceocrpdfollamastructured-outputpostgresqlhuman-in-the-loopaccounting
ОБНОВЕНО · 03.10.2026

Фактури от PDF до чернова: локален конвейер

Входящата фактура идва като PDF или снимка. На локален AI сървър от класа NVIDIA GB10 я превръщаме в полета, проверени с обикновен код, и чернова на счетоводна статия — за одобрение от човек. Нищо не се осчетоводява само, а документите не напускат машината.

⏱ 2 ч Напреднало GX10 PyMuPDF · OCR · Ollama · PostgreSQL
PyMuPDF (PDF → текст или картинка)🔒 локално Nemotron OCR (контейнер, урок 04-75)🔒 локално Ollama (извличане на полета)🔒 локално PostgreSQL (опашка за одобрение)🔒 локално
⚖️
ВАЖНО: AI не заменя счетоводител
Конвейерът подготвя данни и чернова. Той не решава как се осчетоводява, не тълкува ЗДДС и не одобрява документ. Решава счетоводителят. Всички фактури в урока са измислени — реални фактури на клиенти не се подават на тестова система без разрешение, защото съдържат лични и търговски данни.
🔄
ОБНОВЕНО · 03.10.2026 — какво
Урокът е преработен. Поправихме: TrOCR по картата си на модела разчита изображения на един ред текст и е обучен върху разписки на английски (набор SROIE); старият код го пускаше върху осем хоризонтални ленти от страницата, което не е „ред“ и не работи за цяла фактура — OCR стъпката е сменена с услугата от урок 04-75, а PDF със вграден текст се чете направо. Моделът llama3.1:14b не съществува (Llama 3.1 е на Ollama само във вариантите 8B, 70B и 405B); извличането на JSON чрез търсене на { и } е заменено със строга схема на Ollama. Сметките на статията бяха вградени в кода (с номера, които не можахме да сверим с официален документ) — сега идват от таблицата на счетоводителя. Валутата е EUR (от 01.01.2026 в България), а не в старата национална валута. Махнахме: таблицата с „97,2 %“, „98,5 %“ и „под 7 секунди“ (нямаше измерване и източник), задачата „тествайте с реални български фактури“ и мъртвата връзка „НСП 2024“. Добавихме: разпознаване на PDF с текст срещу скан, схема с Pydantic, проверки на сумите и ДДС, чернова от таблица, опашка за одобрение в PostgreSQL, защита от двойна обработка, бележки за поверителност и за закона, тест.
⚠️
Какво не сме пускали сами
Нямахме машина от класа GB10 — затова няма етикет „ТЕСТВАНО“. Не сме пускали услугата OCR, Ollama и PostgreSQL, нито цялата верига. Пуснахме само частите, които не зависят от машина — на обикновен компютър, с измислени данни: четене на PDF (текстова и „сканирана“ страница), разчитане на суми, проверките и черновата на статията. Неизмерени остават: скоростта, паметта на GB10 и качеството върху български текст — Llama 3.3 не изброява български сред поддържаните си езици, а за Nemotron OCR не видяхме потвърдени езици. Точността на конвейера не е мерена — измери я върху свои измислени и анонимизирани примери.

01Какво ще научиш

02Преди да започнеш

📜
Какво казва законът (по официални източници, към 03.10.2026)
Закон за данъка върху добавената стойност (ЗДДС): фактурата се издава в 5 дни от датата на данъчното събитие (чл. 113, ал. 4) и съдържа реквизитите по чл. 114, ал. 1 — между тях дума „фактура“, десетразряден номер, дата, данни за доставчика и получателя (име, адрес, ЕИК, ДДС номер), количество и вид стока или услуга, единична цена. Основната ставка е 20 %. От 01.01.2026 в България се използва еврото; сумите по-долу са в EUR. Източници: страницата „Фактуриране“ на НАП (nra.bg) и обнародваният закон в Държавен вестник. ⚠️ Дословната действаща редакция на чл. 113 и 114 не е сверена от нас ред по ред — виж я в Държавен вестник. Електронните фактури: правилата в ЕС и в България се променят (директива ViDA, (ЕС) 2025/516 ⚠️ номерът е от справка, текстът не е отварян от нас); какво е задължително към днес — виж nra.bg. Урокът не дава срокове за въвеждане.
КаквоВерсия (към 03.10.2026)
PyMuPDF (Python)1.26.7 — с нея пуснахме кода на обикновен компютър
pydantic · ollama (Python)2.12.5 · 0.6.3 — същата проверка
Nemotron OCRnemotron-ocr-v2:2.0 — виж 04-75
PostgreSQL18

03Стъпки

  1. Какво стои къде

    Правилото на целия конвейер: моделът само чете. Не смята, не решава и не осчетоводява. Сумите ги проверява обикновен код, а решението е на човек.

    ЧастЗа какво еДостъп
    PyMuPDFОтваря PDF: вграден текст или картинка на страницав процеса на Python
    OCR услугата (04-75)Текст от изображение, с координатисамо localhost:8000
    OllamaПревръща текста в полета по схемасамо на машината
    Обикновен кодПроверки на сумите, чернова на статиялокално
    PostgreSQLОпашка за одобрениесамо 127.0.0.1:5432
  2. PDF: има ли вграден текст?

    Много PDF фактури (от счетоводни програми) вече носят текст — тогава OCR е излишен и по-малко точен от самия текст. Скановете са картинки без текст. Кодът пита всяка страница: ако има поне 40 знака, взема текста; иначе я рисува като JPEG на 200 dpi за OCR. Пътят се подава като обикновен низ, а PDF файлът се затваря след четене.

    bash
    pip install pymupdf httpx ollama pydantic "psycopg[binary]"
    python · pdf_pages.py
    import pymupdf
    
    MIN_TEXT_CHARS = 40
    
    
    def pdf_to_pages(path: str, dpi: int = 200) -> list[dict]:
        """За всяка страница: вграден текст, ако има, иначе JPEG за OCR."""
        pages = []
        with pymupdf.open(path) as doc:
            for page in doc:
                text = page.get_text().strip()
                if len(text) >= MIN_TEXT_CHARS:
                    pages.append({"kind": "text", "text": text})
                else:
                    pix = page.get_pixmap(dpi=dpi)
                    pages.append({"kind": "image", "jpeg": pix.tobytes("jpeg")})
        return pages
    💡
    Защо не TrOCR
    Картата на microsoft/trocr-large-printed казва, че моделът е за OCR на изображения на един ред текст и е донастроен върху разписки (SROIE). Цяла фактура има колони, таблици и печати — нужен е OCR, който сам намира текстовите блокове, какъвто е услугата от 04-75. Ленти от страницата не са редове.
  3. OCR само за страниците-картинки

    Заявката е същата като в 04-75 (POST /v1/ocr, image_url с JPEG в base64). Документацията не обещава ред на откритите блокове, затова ги подреждаме по горния ляв ъгъл — просто правило, което при колони не стига. ⚠️ Не е пускано от нас.

    python · ocr_pages.py
    import base64
    
    import httpx
    
    NIM_URL = "http://localhost:8000/v1/ocr"       # услугата от урок 04-75
    
    
    def ocr_jpeg(jpeg: bytes, timeout: float = 120.0) -> str:
        b64 = base64.b64encode(jpeg).decode("ascii")
        payload = {"input": [{"type": "image_url", "url": f"data:image/jpeg;base64,{b64}"}]}
        r = httpx.post(NIM_URL, json=payload, timeout=timeout)
        r.raise_for_status()
        blocks = []
        for det in r.json()["data"][0]["text_detections"]:
            pts = det["bounding_box"]["points"]
            blocks.append((round(min(p["y"] for p in pts), 2), min(p["x"] for p in pts),
                           det["text_prediction"]["text"]))
        blocks.sort()                                # реда не е обещан от документацията
        return "\n".join(text for _, _, text in blocks)
    
    
    def pages_to_text(pages: list[dict]) -> str:
        parts = [p["text"] if p["kind"] == "text" else ocr_jpeg(p["jpeg"]) for p in pages]
        return "\n\n".join(parts)
  4. Строга схема: от текст към полета

    Схемата описва какво искаме. Ollama може да ограничи отговора до JSON схема (параметър format), а Pydantic после проверява резултата. Суми и дати са текст: така преписваме каквото пише, а числото го разчита нашият код. В подканата казваме изрично: „не смятай, не поправяй“, а температурата е 0, както съветва документацията за по-предсказуеми отговори.

    python · invoice_schema.py
    from typing import Literal
    from pydantic import BaseModel
    
    
    class Line(BaseModel):
        description: str
        total: str | None = None          # сума като текст с точка, напр. "120.50"
    
    
    class Invoice(BaseModel):
        invoice_number: str | None = None
        issue_date: str | None = None     # ГГГГ-ММ-ДД
        supplier_name: str | None = None
        supplier_vat_id: str | None = None
        buyer_name: str | None = None
        currency: str | None = None       # "EUR" или друг код
        subtotal: str | None = None       # без ДДС
        vat_rate: str | None = None       # напр. "20"
        vat_amount: str | None = None
        total: str | None = None          # с ДДС
        lines: list[Line] = []
        expense_category: Literal["materials", "services", "utilities", "rent", "other"] | None = None
    python · extract.py
    from ollama import chat
    
    from invoice_schema import Invoice
    
    MODEL = "llama3.3:70b"        # пример; ползвай модел, който си изтеглил и тествал
    
    PROMPT = """Ти извличаш данни от текст на фактура. Ползвай САМО текста по-долу.
    Ако поле липсва, остави null. НЕ смятай и НЕ поправяй суми — препиши ги както са.
    Суми — като текст с точка за десетична запетая (напр. "120.50"). Дата — ГГГГ-ММ-ДД.
    expense_category — само ако е ясна от текста, иначе null.
    
    ТЕКСТ:
    ---
    {text}
    ---"""
    
    
    def extract_invoice(text: str) -> Invoice:
        resp = chat(
            model=MODEL,
            messages=[{"role": "user", "content": PROMPT.format(text=text[:12000])}],
            format=Invoice.model_json_schema(),
            options={"temperature": 0},
        )
        return Invoice.model_validate_json(resp.message.content)

    Ако моделът върне нещо невалидно по схемата, model_validate_json хвърля грешка — фактурата отива при човек, а не в базата.

  5. Проверки с обикновен код

    Моделът може да препише „120,50“ като „102,50“. Затова всяка фактура минава през проверки, които никой модел не влияе. Празният списък не значи „вярно“ — значи само „тези проверки не намериха грешка“.

    python · invoice_checks.py
    import re
    from datetime import date
    from decimal import Decimal, InvalidOperation
    
    CENT = Decimal("0.01")
    
    
    def money(value: str | None) -> Decimal | None:
        """'1 234,56' -> Decimal('1234.56'); неразчетимото -> None."""
        if value is None:
            return None
        s = re.sub(r"[^\d,.\-]", "", value.replace(" ", " "))
        if "," in s and "." in s:
            s = s.replace(",", "") if s.rfind(".") > s.rfind(",") else s.replace(".", "").replace(",", ".")
        elif "," in s:
            s = s.replace(",", ".")
        try:
            return Decimal(s)
        except InvalidOperation:
            return None
    
    
    def check_invoice(inv, today: date | None = None) -> list[str]:
        """Връща списък със забележки. Празен списък НЕ значи „вярно“, а само „не намерихме грешка“."""
        today = today or date.today()
        issues = []
        for name in ("invoice_number", "issue_date", "supplier_name", "subtotal", "vat_amount", "total"):
            if not getattr(inv, name):
                issues.append(f"липсва поле: {name}")
    
        net, vat, total = money(inv.subtotal), money(inv.vat_amount), money(inv.total)
        if None not in (net, vat, total) and abs(net + vat - total) > CENT:
            issues.append(f"{net} + {vat} не прави {total}")
    
        rate = money(inv.vat_rate)
        if rate is not None and net is not None and vat is not None:
            expected = (net * rate / 100).quantize(CENT)
            if abs(expected - vat) > CENT:
                issues.append(f"ДДС при ставка {rate} % би било {expected}, а е {vat}")
        if rate is not None and rate != 20:
            issues.append(f"ставка {rate} % (основната е 20 %) — за човека")
    
        line_sum = [money(l.total) for l in inv.lines]
        if inv.lines and None not in line_sum and net is not None and abs(sum(line_sum) - net) > CENT:
            issues.append(f"редовете правят {sum(line_sum)}, а основата е {net}")
    
        if inv.issue_date:
            try:
                d = date.fromisoformat(inv.issue_date)
                if d > today:
                    issues.append("датата е в бъдещето")
            except ValueError:
                issues.append("датата не е във вид ГГГГ-ММ-ДД")
    
        if inv.invoice_number and not re.fullmatch(r"\d{10}", inv.invoice_number.strip()):
            issues.append("номерът не е от 10 цифри (за българска фактура ЗДДС изисква десетразряден номер — при чужд доставчик е нормално)")
        if inv.currency and inv.currency.upper() != "EUR":
            issues.append(f"валута {inv.currency}, не EUR — за човека")
        return issues
    ПроверкаЗащо
    Липсващи полетаБез номер, дата, доставчик и суми няма какво да се осчетоводява.
    Основа + ДДС = общо (с точност до 1 цент)Улавя грешно прочетена цифра.
    ДДС = основа × ставкаОсновната ставка е 20 %; друга ставка се вдига към човек, не се отхвърля.
    Сума на редовете = основаВтора независима сметка.
    Дата не е в бъдещетоУлавя объркан месец или година.
    Номер от 10 цифриЗДДС изисква десетразряден номер; при чужд доставчик е нормално да е друг, затова е само забележка.
    Валута EURВсичко друго отива при човек.
  6. Чернова на статия — от таблицата на счетоводителя

    Сметките идват от файл, който пази счетоводителят — нито кодът, нито моделът ги избират. Ако категорията липсва или сумите не се събират точно, черновата не се прави (връща се None) и решава човек. Съдържанието на примерния файл са заместители: попълни ги с кодовете от сметкоплана на твоето дружество. ⚠️ Нямаме официален източник за конкретни номера и не даваме такива.

    json · accounts.json
    {
      "materials": "<сметка за разходи за материали>",
      "services":  "<сметка за разходи за външни услуги>",
      "utilities": "<сметка за комунални разходи — по политиката на дружеството>",
      "rent":      "<сметка за наеми>",
      "other":     "<сметка за други разходи>",
      "vat_input": "<сметка за ДДС на покупките>",
      "suppliers": "<сметка за доставчици>"
    }
    python · journal.py
    from decimal import Decimal
    
    from invoice_checks import money
    
    
    def draft_entry(inv, accounts: dict) -> list[tuple[str, str, Decimal]] | None:
        """Чернова на счетоводна статия. Сметките идват от сметкоплана на дружеството
        (файл accounts.json), а не от кода или от модела."""
        expense = accounts.get(inv.expense_category or "")
        net, vat, total = money(inv.subtotal), money(inv.vat_amount), money(inv.total)
        if not expense or None in (net, vat, total) or net + vat != total:
            return None                                # няма чернова -> решава човек
        return [("Дт", expense, net),
                ("Дт", accounts["vat_input"], vat),
                ("Кт", accounts["suppliers"], total)]
  7. Опашка за одобрение в PostgreSQL

    Всичко влиза със състояние pending. Контролната сума на файла (sha256) е уникална — същият файл не влиза втори път. Само човек променя състоянието, и то само от pending.

    bash · на машината
    docker run -d --name invoices-db \
      -e POSTGRES_PASSWORD='<парола>' -e POSTGRES_DB=invoices \
      -e PGDATA=/var/lib/postgresql/data \
      -v invoices_db:/var/lib/postgresql/data \
      -p 127.0.0.1:5432:5432 \
      postgres:18
    sql
    CREATE TABLE invoice_inbox (
        id           BIGSERIAL PRIMARY KEY,
        file_sha256  TEXT NOT NULL UNIQUE,          -- същият файл не се обработва два пъти
        received_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
        fields       JSONB NOT NULL,                -- каквото е извлякъл моделът
        issues       JSONB NOT NULL DEFAULT '[]',   -- забележките от проверките
        draft_entry  JSONB,                         -- чернова на статия или NULL
        status       TEXT NOT NULL DEFAULT 'pending'
                     CHECK (status IN ('pending', 'approved', 'rejected')),
        decided_by   TEXT,
        decided_at   TIMESTAMPTZ
    );
    bash
    export INVOICE_DB_DSN="postgresql://postgres:<парола>@localhost:5432/invoices"
    python · pipeline.py
    import hashlib
    import json
    import os
    
    import psycopg
    
    from extract import extract_invoice
    from invoice_checks import check_invoice
    from journal import draft_entry
    from ocr_pages import pages_to_text
    from pdf_pages import pdf_to_pages
    
    DSN = os.environ["INVOICE_DB_DSN"]             # postgresql://<потребител>:<парола>@localhost:5432/invoices
    ACCOUNTS = json.load(open("accounts.json", encoding="utf-8"))
    
    
    def process(path: str) -> str:
        with open(path, "rb") as f:
            sha = hashlib.sha256(f.read()).hexdigest()
        with psycopg.connect(DSN) as conn:
            if conn.execute("SELECT 1 FROM invoice_inbox WHERE file_sha256 = %s", (sha,)).fetchone():
                return "вече е обработена"
    
        inv = extract_invoice(pages_to_text(pdf_to_pages(path)))
        issues = check_invoice(inv)
        entry = draft_entry(inv, ACCOUNTS)
        entry_json = [[side, acc, str(amount)] for side, acc, amount in entry] if entry else None
    
        with psycopg.connect(DSN) as conn:
            conn.execute(
                "INSERT INTO invoice_inbox (file_sha256, fields, issues, draft_entry) "
                "VALUES (%s, %s::jsonb, %s::jsonb, %s::jsonb)",
                (sha, inv.model_dump_json(), json.dumps(issues, ensure_ascii=False),
                 json.dumps(entry_json, ensure_ascii=False) if entry_json else None),
            )
        return f"на опашка, забележки: {len(issues)}"
    
    
    def decide(invoice_id: int, status: str, who: str) -> bool:
        """Само човек вика това. status е 'approved' или 'rejected'."""
        assert status in ("approved", "rejected")
        with psycopg.connect(DSN) as conn:
            cur = conn.execute(
                "UPDATE invoice_inbox SET status = %s, decided_by = %s, decided_at = now() "
                "WHERE id = %s AND status = 'pending'", (status, who, invoice_id))
            return cur.rowcount == 1

    Паролата е в променлива на средата и не е в кода. Оригиналите на PDF файловете пази отделна папка с ограничени права; колко време се съхраняват счетоводните документи, определя Законът за счетоводството ⚠️ не сме го сверявали — питай счетоводителя.

  8. Пробвай с измислени данни

    Частите без OCR и база можеш да изпробваш веднага. Същата проверка пуснахме и ние (на обикновен компютър, не на GB10); пробвай я и ти. Проверихме и pdf_to_pages с PDF от две страници — една с текст и една „сканирана“ — и върна text и image.

    bash
    python3 -c "
    from decimal import Decimal
    from invoice_schema import Invoice, Line
    from invoice_checks import check_invoice
    from journal import draft_entry
    inv = Invoice(invoice_number='0000000001', issue_date='2026-09-30', supplier_name='Примерна Фирма ООД',
                  currency='EUR', subtotal='100.00', vat_rate='20', vat_amount='20.00', total='120.00',
                  lines=[Line(description='Услуга', total='100.00')], expense_category='services')
    print(check_invoice(inv))
    print(draft_entry(inv, {'services': 'РАЗХОД', 'vat_input': 'ДДС', 'suppliers': 'ДОСТАВЧИЦИ'}))
    "

    Очакваш:

    изход
    []
    [('Дт', 'РАЗХОД', Decimal('100.00')), ('Дт', 'ДДС', Decimal('20.00')), ('Кт', 'ДОСТАВЧИЦИ', Decimal('120.00'))]

    Сега в примера смени vat_amount на '25.00' и issue_date на '2099-01-01' — check_invoice връща:

    изход
    100.00 + 25.00 не прави 120.00
    ДДС при ставка 20 % би било 20.00, а е 25.00
    датата е в бъдещето
  9. Правила, които не се пропускат

    • Нищо не се осчетоводява от конвейера: състоянието approved го поставя човек.
    • Базата, OCR услугата и Ollama са достъпни само на самата машина; паролата е в променлива на средата.
    • Фактурите съдържат имена и адреси — пази ги с права за достъп и срок за съхранение, определен от счетоводителя.
    • Не обещавай „процент точност“, докато не си го измерил върху свои примери.
    • Ако доставчикът праща структурирана електронна фактура (данни, не картинка), чети данните направо — OCR не е нужен.

04Проверка

Тест

1. Защо старият урок с TrOCR върху осем ленти не върши работа за цяла фактура?

2. Какво значи празен списък от check_invoice?

3. Откъде идват сметките за черновата на статията?

4. Защо се пази контролна сума (sha256) на файла?

05Какво следва

06Източници

  1. Hugging Face: microsoft/trocr-large-printed — карта на модела (OCR на изображения на един ред текст; донастроен върху SROIE); прочетена 03.10.2026.
  2. PyMuPDF: документация 🔒 локално — get_text, get_pixmap; кодът е пускан с версия 1.26.7.
  3. Ollama: структурирани отговори 🔒 локално — format със схема от Pydantic, температура 0.
  4. Ollama: llama3.1 · llama3.3 🔒 локално — размери (llama3.1: 8B, 70B, 405B) и поддържани езици.
  5. NVIDIA NIM за Image OCR — заявка и отговор, виж урок 04-75.
  6. НАП: Фактуриране · Държавен вестник — ЗДДС, чл. 113 и 114 (⚠️ дословната редакция не е сверена от нас).