Фактури от PDF до чернова: локален конвейер
Входящата фактура идва като PDF или снимка. На локален AI сървър от класа NVIDIA GB10 я превръщаме в полета, проверени с обикновен код, и чернова на счетоводна статия — за одобрение от човек. Нищо не се осчетоводява само, а документите не напускат машината.
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, защита от двойна обработка, бележки за поверителност и за закона, тест.
01Какво ще научиш
- Как да различиш PDF с вграден текст от скан и да пускаш OCR само когато е нужно.
- Как да накараш локален модел да върне полетата на фактурата по строга схема.
- Защо сумите се проверяват с обикновен код, а не с модела, и кои проверки да имаш.
- Как от таблицата на счетоводителя се прави чернова на статия.
- Как се държи опашка за одобрение и как се избягва двойната обработка на един файл.
- Какво не бива да се обещава за точността.
02Преди да започнеш
- Машина от класа NVIDIA GB10 и работеща OCR услуга от урок 04-75 на
localhost:8000. Проверките на ЕИК и ДДС номер (по контролна сума и във VIES) са в урок 04-127 — тук не ги повтаряме. - Ollama с изтеглен модел. Примерът ползва
llama3.3:70b(около 43 GB по ollama.com, обща памет с процесора) — стекът на Ollama е в 04-01. ⚠️ За български не е гарантирано качество — виж бележката горе. - PostgreSQL 18 (виж 04-01) и Python 3.
- Измислени фактури: направи си 3–5 PDF и 3–5 снимки с измислени доставчици и суми. Реални фактури не слагай.
- От счетоводителя — таблица „категория → сметка“ от сметкоплана на дружеството. Сметките не ги измисляме ние и не ги избира моделът.
| Какво | Версия (към 03.10.2026) |
|---|---|
| PyMuPDF (Python) | 1.26.7 — с нея пуснахме кода на обикновен компютър |
| pydantic · ollama (Python) | 2.12.5 · 0.6.3 — същата проверка |
| Nemotron OCR | nemotron-ocr-v2:2.0 — виж 04-75 |
| PostgreSQL | 18 |
03Стъпки
-
Какво стои къде
Правилото на целия конвейер: моделът само чете. Не смята, не решава и не осчетоводява. Сумите ги проверява обикновен код, а решението е на човек.
Част За какво е Достъп PyMuPDF Отваря PDF: вграден текст или картинка на страница в процеса на Python OCR услугата (04-75) Текст от изображение, с координати само localhost:8000Ollama Превръща текста в полета по схема само на машината Обикновен код Проверки на сумите, чернова на статия локално PostgreSQL Опашка за одобрение само 127.0.0.1:5432 -
PDF: има ли вграден текст?
Много PDF фактури (от счетоводни програми) вече носят текст — тогава OCR е излишен и по-малко точен от самия текст. Скановете са картинки без текст. Кодът пита всяка страница: ако има поне 40 знака, взема текста; иначе я рисува като JPEG на 200 dpi за OCR. Пътят се подава като обикновен низ, а PDF файлът се затваря след четене.
bashpip install pymupdf httpx ollama pydantic "psycopg[binary]"python · pdf_pages.pyimport 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. Ленти от страницата не са редове. -
OCR само за страниците-картинки
Заявката е същата като в 04-75 (
POST /v1/ocr,image_urlс JPEG в base64). Документацията не обещава ред на откритите блокове, затова ги подреждаме по горния ляв ъгъл — просто правило, което при колони не стига. ⚠️ Не е пускано от нас.python · ocr_pages.pyimport 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) -
Строга схема: от текст към полета
Схемата описва какво искаме. Ollama може да ограничи отговора до JSON схема (параметър
format), а Pydantic после проверява резултата. Суми и дати са текст: така преписваме каквото пише, а числото го разчита нашият код. В подканата казваме изрично: „не смятай, не поправяй“, а температурата е 0, както съветва документацията за по-предсказуеми отговори.python · invoice_schema.pyfrom 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 = Nonepython · extract.pyfrom 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хвърля грешка — фактурата отива при човек, а не в базата. -
Проверки с обикновен код
Моделът може да препише „120,50“ като „102,50“. Затова всяка фактура минава през проверки, които никой модел не влияе. Празният списък не значи „вярно“ — значи само „тези проверки не намериха грешка“.
python · invoice_checks.pyimport 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 Всичко друго отива при човек. -
Чернова на статия — от таблицата на счетоводителя
Сметките идват от файл, който пази счетоводителят — нито кодът, нито моделът ги избират. Ако категорията липсва или сумите не се събират точно, черновата не се прави (връща се
None) и решава човек. Съдържанието на примерния файл са заместители: попълни ги с кодовете от сметкоплана на твоето дружество. ⚠️ Нямаме официален източник за конкретни номера и не даваме такива.json · accounts.json{ "materials": "<сметка за разходи за материали>", "services": "<сметка за разходи за външни услуги>", "utilities": "<сметка за комунални разходи — по политиката на дружеството>", "rent": "<сметка за наеми>", "other": "<сметка за други разходи>", "vat_input": "<сметка за ДДС на покупките>", "suppliers": "<сметка за доставчици>" }python · journal.pyfrom 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)] -
Опашка за одобрение в 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:18sqlCREATE 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 );bashexport INVOICE_DB_DSN="postgresql://postgres:<парола>@localhost:5432/invoices"python · pipeline.pyimport 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 файловете пази отделна папка с ограничени права; колко време се съхраняват счетоводните документи, определя Законът за счетоводството ⚠️ не сме го сверявали — питай счетоводителя.
-
Пробвай с измислени данни
Частите без OCR и база можеш да изпробваш веднага. Същата проверка пуснахме и ние (на обикновен компютър, не на GB10); пробвай я и ти. Проверихме и
pdf_to_pagesс PDF от две страници — една с текст и една „сканирана“ — и върнаtextиimage.bashpython3 -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 датата е в бъдещето -
Правила, които не се пропускат
- Нищо не се осчетоводява от конвейера: състоянието
approvedго поставя човек. - Базата, OCR услугата и Ollama са достъпни само на самата машина; паролата е в променлива на средата.
- Фактурите съдържат имена и адреси — пази ги с права за достъп и срок за съхранение, определен от счетоводителя.
- Не обещавай „процент точност“, докато не си го измерил върху свои примери.
- Ако доставчикът праща структурирана електронна фактура (данни, не картинка), чети данните направо — OCR не е нужен.
- Нищо не се осчетоводява от конвейера: състоянието
04Проверка
- Кодът за PDF разпознава текстова страница и страница-картинка.
- OCR услугата отговаря на
/v1/health/ready, аocr_jpegвръща текст за измислена картинка. - Извличането връща валиден
Invoice; невалиден отговор не стига до базата. check_invoiceулавя нарочно объркана сума, ДДС и дата.draft_entryвръщаNoneпри липсваща категория.- Същият файл подаден втори път връща „вече е обработена“.
- Сметките са от таблицата на счетоводителя; в кода няма пароли и реални данни.
Тест
1. Защо старият урок с TrOCR върху осем ленти не върши работа за цяла фактура?
2. Какво значи празен списък от check_invoice?
3. Откъде идват сметките за черновата на статията?
4. Защо се пази контролна сума (sha256) на файла?
05Какво следва
06Източници
- Hugging Face: microsoft/trocr-large-printed — карта на модела (OCR на изображения на един ред текст; донастроен върху SROIE); прочетена 03.10.2026.
- PyMuPDF: документация 🔒 локално —
get_text,get_pixmap; кодът е пускан с версия 1.26.7. - Ollama: структурирани отговори 🔒 локално —
formatсъс схема от Pydantic, температура 0. - Ollama: llama3.1 · llama3.3 🔒 локално — размери (llama3.1: 8B, 70B, 405B) и поддържани езици.
- NVIDIA NIM за Image OCR — заявка и отговор, виж урок 04-75.
- НАП: Фактуриране · Държавен вестник — ЗДДС, чл. 113 и 114 (⚠️ дословната редакция не е сверена от нас).