Операционные и финансовые отделы тратят часы на перепечатку счетов, заказов на закупку, накладных и коммерческих предложений поставщиков в таблицы. С помощью OCR, который читает сканы и фотографии, и локальной языковой модели, возвращающей ровно те поля, которые вы задали, большую часть этого ручного ввода можно автоматизировать. В статье Tesseract и OCRmyPDF объединяются со структурированным выводом (structured output) Ollama: каждый документ превращается в JSON-файл с извлечёнными полями, а сводная таблица отмечает документы, у которых сумма позиций не сходится с итогом, — так людям остаётся проверить только исключения. Всё работает на вашем собственном сервере.
# Debian / Ubuntu: PDF text, OCR engine with English and Simplified Chinese, OCR for PDFs
apt install -y python3-venv poppler-utils tesseract-ocr tesseract-ocr-eng tesseract-ocr-chi-sim ocrmypdf
python3 -m venv /opt/extract
/opt/extract/bin/pip install ollama
ollama pull qwen2.5:7b
Настройка Ollama описана в статье о запуске локальной LLM с Ollama. Для других языков установите соответствующий пакет Tesseract, например tesseract-ocr-jpn, tesseract-ocr-tha или tesseract-ocr-rus.
# Digital PDF (created by software): the text can be read directly
pdftotext -layout invoice.pdf - | head -40
# Scanned PDF: add a text layer with OCR first, then read it
ocrmypdf -l eng+chi_sim --skip-text scan.pdf scan-ocr.pdf
pdftotext -layout scan-ocr.pdf - | head -40
# Photo or screenshot
tesseract photo.jpg - -l eng+chi_sim | head -40
Если текст здесь читается, модель сможет извлечь из него поля. Если получается бессмыслица, сначала улучшите исходные материалы: сканируйте с разрешением 300 dpi, фотографируйте документы ровно и при хорошем освещении.
Схема SCHEMA определяет, какие поля вам нужны. Ollama принудительно приводит ответ модели ровно к этой структуре, поэтому на выходе всегда получается корректный JSON.
cat > /opt/extract/extract.py <<'EOF2'
import csv, json, os, subprocess, sys, tempfile
import ollama
MODEL = os.environ.get("MODEL", "qwen2.5:7b")
LANG = os.environ.get("OCR_LANG", "eng+chi_sim")
SCHEMA = {
"type": "object",
"properties": {
"supplier": {"type": "string"},
"document_number": {"type": "string"},
"date": {"type": "string", "description": "YYYY-MM-DD"},
"currency": {"type": "string"},
"total": {"type": "number"},
"tax": {"type": "number"},
"items": {"type": "array", "items": {"type": "object", "properties": {
"description": {"type": "string"}, "quantity": {"type": "number"},
"unit_price": {"type": "number"}, "amount": {"type": "number"}},
"required": ["description", "quantity", "amount"]}},
},
"required": ["supplier", "document_number", "date", "currency", "total", "tax", "items"],
}
def text_of(path):
run = lambda cmd: subprocess.run(cmd, capture_output=True, text=True).stdout
if not path.lower().endswith(".pdf"):
return run(["tesseract", path, "-", "-l", LANG])
text = run(["pdftotext", "-layout", path, "-"])
if len(text.strip()) > 50:
return text
with tempfile.TemporaryDirectory() as tmp: # scanned PDF: OCR it first
ocr = os.path.join(tmp, "ocr.pdf")
subprocess.run(["ocrmypdf", "-q", "-l", LANG, "--skip-text", path, ocr], check=True)
return run(["pdftotext", "-layout", ocr, "-"])
rows = []
for path in sys.argv[1:]:
if not os.path.isfile(path):
continue
r = ollama.chat(model=MODEL, format=SCHEMA, options={"temperature": 0, "num_ctx": 8192}, messages=[
{"role": "system", "content": "Extract the requested fields from the document text. "
"Copy names, numbers and IDs exactly as written. Use an empty string or 0 "
"for anything that is not in the text. Do not guess."},
{"role": "user", "content": text_of(path)[:15000]}])
d = json.loads(r.message.content)
items = round(sum(i.get("amount", 0) for i in d["items"]), 2)
ok = d["total"] > 0 and min(abs(items - d["total"]), abs(items + d["tax"] - d["total"])) < 0.02
with open(os.path.splitext(path)[0] + ".json", "w", encoding="utf-8") as f:
json.dump(d, f, ensure_ascii=False, indent=2)
rows.append([os.path.basename(path), d["supplier"], d["document_number"], d["date"],
d["currency"], d["total"], d["tax"], len(d["items"]), "OK" if ok else "CHECK"])
print(path, "OK" if ok else "CHECK", flush=True)
with open("summary.csv", "w", newline="", encoding="utf-8-sig") as f:
w = csv.writer(f)
w.writerow(["file", "supplier", "document_number", "date", "currency", "total", "tax", "lines", "check"])
w.writerows(rows)
EOF2
cd /data/invoices/2026-10
/opt/extract/bin/python /opt/extract/extract.py *.pdf *.jpg *.png
# For a large folder, run it in the background:
nohup /opt/extract/bin/python /opt/extract/extract.py *.pdf > extract.log 2>&1 &
Для каждого документа рядом с ним создаётся файл .json со всеми полями и позициями, а в summary.csv каждому документу соответствует одна строка. Откройте его в Excel и отфильтруйте столбец check: OK означает, что сумма позиций сходится с итогом (с налогом или без), CHECK — что этот документ должен посмотреть человек.
buyer, delivery_date и поля SKU в позициях.tracking_number, consignee, weight_kg и packages.valid_until, payment_terms и цены за единицу, чтобы сравнивать поставщиков в одной таблице.Короткий description у поля, как у date выше, подсказывает модели ожидаемый формат. Если вы обрабатываете несколько типов документов, держите отдельную схему для каждого типа в отдельных скриптах или папках.
qwen2.5:14b, лучше справляются со сложной вёрсткой и длинными таблицами, но работают медленнее.На CPU-сервере — обычно от нескольких десятков секунд для одностраничного счёта до нескольких минут для длинных многостраничных документов, в зависимости от модели. Большие пакеты запускайте на ночь.
Многие ИИ API тоже поддерживают структурированный вывод, но тогда документы покидают ваш сервер. Сначала проверьте свои обязательства по защите данных, условия провайдера и поддерживаемые им регионы. Для счетов, договоров и данных клиентов локальная схема обычно безопаснее.
Большинство систем умеют импортировать CSV-файлы: сопоставьте столбцы summary.csv или преобразуйте JSON-файлы в формат импорта. Автоматически импортируйте только строки с отметкой OK, а строки CHECK оставляйте для ручной проверки.
Если проблема не решена, создайте тикет — техническая поддержка IMIDC работает 24/7. Укажите IP сервера, версию ОС, используемую модель и вывод с ошибкой, а по возможности приложите пример документа без конфиденциальных данных, чтобы мы быстрее нашли причину.