З фото накладної у чистий JSON: vision-модель замість OCR
Задача звучить просто: є фото видаткової накладної, і з нього треба отримати структуровані дані. Постачальник, покупець, кожен рядок таблиці з цінами, підсумки, ПДВ. І не текстом, а чистим JSON, який можна одразу класти в базу чи прокидати далі по пайплайну.
Ще кілька років тому це означало б Tesseract, регулярки і багато болю: класичний OCR повертає таблицю як кашу з рядків, а бланки накладних у кожного постачальника свої. Сучасні vision-моделі знімають цю проблему: модель бачить документ цілком, розуміє структуру таблиці й повертає відповідь одразу в заданій схемі. Це працює однаково на OpenAI і на Claude. Я реалізовував на OpenAI, тож далі покажу саме цей варіант: NestJS + LangChain + gpt-4o-mini.
Схема — це контракт
Перше і найважливіше рішення: не просити модель «поверни JSON», а зафіксувати схему відповіді через structured output. Схему описую в Zod і віддаю моделі: LangChain конвертує її в JSON Schema, а API гарантує, що відповідь пройде валідацію:
const itemSchema = z.object({
position: z.number().int().describe("Номер рядка з колонки №"),
name: z.string().describe("Найменування товару як у документі"),
quantity: z.number(),
unit: z.string().nullable().describe("Одиниця виміру: шт, банки, лист"),
priceWithoutVat: z.number().nullable().describe("Ціна за одиницю без ПДВ"),
amountWithoutVat: z.number().nullable().describe("Сума по рядку без ПДВ"),
vatRate: z.number().nullable().describe("Ставка ПДВ у відсотках, напр. 20"),
vatAmount: z.number().nullable(),
priceWithVat: z.number().nullable(),
amountWithVat: z.number().nullable(),
});
const invoiceSchema = z.object({
title: z.string().nullable().describe('Заголовок, напр. "Видаткова накладна"'),
number: z.string().nullable().describe("Номер документа без символу №"),
date: z.string().nullable().describe("Дата документа у форматі YYYY-MM-DD"),
currency: z.string().nullable().describe("Валюта у форматі ISO, напр. UAH"),
supplier: partySchema,
buyer: partySchema,
contract: z.object({
number: z.string().nullable(),
date: z.string().nullable().describe("YYYY-MM-DD"),
}),
items: z.array(itemSchema),
totals: totalsSchema,
});Тут два неочевидні моменти, які я зрозумів не одразу:
- nullable, а не optional. Strict-режим structured output вимагає, щоб усі ключі були присутні у відповіді. «Цього немає в документі» передаємо явно через
null, тож парсер ніколи не ламається на відсутньому полі. - describe() — це міні-промпт. Саме з описів полів модель дізнається, що
unit— це «шт, банки, лист», а дату треба нормалізувати. Що точніші описи, то менше сюрпризів у відповіді.
Промпт із правилами домену
Схема каже, *що* повернути. System prompt каже, *як* читати документ:
const SYSTEM_PROMPT = [
"Ти витягуєш дані з української видаткової накладної на фото чи скані.",
"Переписуй значення рівно так, як вони надруковані: не перекладай назви товарів і не виправляй орфографію.",
"Числа повертай як числа: десятковий роздільник — крапка, розділювачі тисяч прибирай (1 975,00 → 1975.00).",
"Дати нормалізуй у YYYY-MM-DD.",
"Чого немає в документі — став null. Нічого не вигадуй і не додавай рядків, яких немає в таблиці.",
"Підписи колонок у таких бланках бувають помилкові: якщо підпис суперечить арифметиці, довіряй числам і стандартній ставці ПДВ 20%.",
].join(" ");Найцікавіший — останній рядок. У реальних бланках підписи полів бувають помилкові: у моїй тестовій накладній рядок «Сума без ПДВ: 395,00» — це насправді сума ПДВ, а 1975,00, підписана «Всього», — якраз сума без ПДВ. Якщо не сказати моделі довіряти арифметиці, вона слухняно перепише помилкові підписи, і в базу поїде ПДВ 395 грн у полі «сума без ПДВ».
Як віддати моделі фото
Модель не має доступу до файлової системи: вона приймає або публічний URL, або data URI. Локальний файл читаємо самі й пакуємо в base64:
async function toImageUrl(source: string): Promise<string> {
if (/^https?:\/\//i.test(source)) return source;
const path = resolve(process.cwd(), source);
const mime = MIME_BY_EXTENSION[extname(path).toLowerCase()];
const file = await readFile(path);
return `data:${mime};base64,${file.toString("base64")}`;
}Сам виклик — звичайний chat completion, тільки з картинкою в повідомленні. temperature: 0, бо творчість тут не потрібна:
const model = new ChatOpenAI({
apiKey,
model: "gpt-4o-mini",
temperature: 0,
}).withStructuredOutput(invoiceSchema, {
name: "invoice",
includeRaw: true,
});
const { raw, parsed } = await model.invoke([
new SystemMessage(SYSTEM_PROMPT),
new HumanMessage({
content: [
{
type: "text",
text: "Витягни всі дані з цієї накладної: шапку, сторони, кожен рядок таблиці та підсумки.",
},
{ type: "image_url", image_url: { url: imageUrl, detail: "high" } },
],
}),
]);Зверни увагу на detail: "high". Без нього API стискає картинку, і дрібний текст у таблиці зчитується з помилками. Ціна — суттєво більше вхідних токенів, але для документів це не опція, а необхідність.
Результат

Ось що повертає gpt-4o-mini за один запит по цьому скану:
{
"title": "ВИДАТКОВА НАКЛАДНА",
"number": "3",
"date": "2024-01-04",
"currency": "UAH",
"supplier": {
"name": "ТОВ «Едельвейс»",
"address": "14032, м. Чернігів, вул. 1-ї танкової бригади, 17",
"phone": "+380(462)151554",
"iban": "UA813003350000026003333333333",
"bank": "АКБ «Аваль»",
"taxCode": null
},
"buyer": {
"name": "ТОВ «Орхідея»",
"address": "14032, м. Чернігів, вул. Донечка, 35",
"phone": "+380(462)181864",
"iban": "UA813003350000026003333333333",
"bank": "АКБ «Приватбанк»",
"taxCode": null
},
"contract": { "number": "1", "date": "2024-01-03" },
"items": [
{
"position": 1,
"name": "лак меблевий акрил-поліуретановий Trae Lyx Moebel lak (0,25л)",
"quantity": 10,
"unit": "банки",
"priceWithoutVat": 150,
"amountWithoutVat": 1500,
"vatRate": 20,
"vatAmount": 300,
"priceWithVat": 180,
"amountWithVat": 1800
},
{
"position": 2,
"name": "ДВП СТ-40 (2.5мм×2440мм×1220мм)",
"quantity": 5,
"unit": "лист",
"priceWithoutVat": 95,
"amountWithoutVat": 475,
"vatRate": 20,
"vatAmount": 95,
"priceWithVat": 114,
"amountWithVat": 570
}
],
"totals": {
"itemsCount": 2,
"amountWithoutVat": 1975,
"vatAmount": 395,
"amountWithVat": 2370,
"amountInWords": "Дві тисячі триста сімдесят гривень 00 копійок",
"vatInWords": "Триста дев'яносто п'ять гривень 00 копійок"
},
"signedBySupplier": "Садовник В.С.",
"signedByBuyer": "Дубина М.В."
}Підсумки модель розклала правильно: 1975 / 395 / 2370, попри переплутані підписи в бланку. Але без ложки дьогтю не обійшлося: дрібний текст mini читає неідеально. Прізвище «Садчиков» перетворилося на «Садовник», а вулиця «Доценка» — на «Донечка». Для щільних сканів, де важлива кожна літера, варто підняти модель до gpt-4o: коштує дорожче, але дрібний текст читає помітно точніше.
Довіряй, але перевіряй
Модель може помилитися в будь-якій цифрі, тому результат я перевіряю не довірою, а арифметикою:
- кількість × ціна = сума по кожному рядку;
- сума рядків без ПДВ = підсумок «без ПДВ»;
- підсумок без ПДВ + ПДВ = підсумок з ПДВ;
- кількість найменувань у підсумку = кількість розпізнаних рядків.
items.forEach((item) => {
if (item.priceWithoutVat === null || item.amountWithoutVat === null) return;
const expected = item.quantity * item.priceWithoutVat;
if (Math.abs(expected - item.amountWithoutVat) > AMOUNT_TOLERANCE) {
problems.push(
`рядок ${item.position}: ${item.quantity} × ${item.priceWithoutVat} = ${expected}, а в документі ${item.amountWithoutVat}`,
);
}
});Якщо якась перевірка не зійшлася — це сигнал перезапустити запит або віддати документ людині. Для бухгалтерських даних такий запобіжник обов'язковий: помилка на одну цифру коштує значно дорожче, ніж повторний запит.
Скільки це коштує
Structured output у LangChain ховає сире повідомлення моделі, а лічильники токенів живуть саме на ньому, тому в конфігу вище стоїть includeRaw: true:
const usage = isAIMessage(raw) ? raw.usage_metadata : undefined;
const cost =
(usage.input_tokens * PRICE_PER_1M_TOKENS.input +
usage.output_tokens * PRICE_PER_1M_TOKENS.output) /
1_000_000;Мій запит: 28 336 вхідних токенів (майже все — картинка в detail: "high") і 449 вихідних:
вартість запиту: $0.004520 (gpt-4o-mini, вхід 28336, вихід 449 токенів)Менше пів цента за повністю структуровану накладну: з позиціями, сумами, ПДВ і перевіркою арифметики.
Висновки
- Structured output зі схемою замість «поверни JSON»: модель фізично не може повернути щось поза контрактом.
nullable-поля — чесний спосіб сказати «цього немає в документі», без вигадок.- Правила домену — в system prompt: нормалізація чисел і дат, недовіра до помилкових підписів у бланку.
- Арифметична перевірка результату — обов'язковий запобіжник для фінансових даних.
- Дешевій vision-моделі можна довірити цифри, але не дрібний текст: для прізвищ і адрес у щільних сканах беріть модель старшу.
Увесь раннер — одна NestJS-команда на ~300 рядків: nest-commander, LangChain і жодного OCR-двигуна. Той самий підхід без змін працює і з Claude: vision і structured output є в обох провайдерів.