8 хв читання

З фото накладної у чистий JSON: vision-модель замість OCR

AIOpenAILangChainNode.jsOCR

Задача звучить просто: є фото видаткової накладної, і з нього треба отримати структуровані дані. Постачальник, покупець, кожен рядок таблиці з цінами, підсумки, ПДВ. І не текстом, а чистим JSON, який можна одразу класти в базу чи прокидати далі по пайплайну.

Ще кілька років тому це означало б Tesseract, регулярки і багато болю: класичний OCR повертає таблицю як кашу з рядків, а бланки накладних у кожного постачальника свої. Сучасні vision-моделі знімають цю проблему: модель бачить документ цілком, розуміє структуру таблиці й повертає відповідь одразу в заданій схемі. Це працює однаково на OpenAI і на Claude. Я реалізовував на OpenAI, тож далі покажу саме цей варіант: NestJS + LangChain + gpt-4o-mini.

Схема — це контракт

Перше і найважливіше рішення: не просити модель «поверни JSON», а зафіксувати схему відповіді через structured output. Схему описую в Zod і віддаю моделі: LangChain конвертує її в JSON Schema, а API гарантує, що відповідь пройде валідацію:

typescript
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 каже, *як* читати документ:

typescript
const SYSTEM_PROMPT = [
  "Ти витягуєш дані з української видаткової накладної на фото чи скані.",
  "Переписуй значення рівно так, як вони надруковані: не перекладай назви товарів і не виправляй орфографію.",
  "Числа повертай як числа: десятковий роздільник — крапка, розділювачі тисяч прибирай (1 975,00 → 1975.00).",
  "Дати нормалізуй у YYYY-MM-DD.",
  "Чого немає в документі — став null. Нічого не вигадуй і не додавай рядків, яких немає в таблиці.",
  "Підписи колонок у таких бланках бувають помилкові: якщо підпис суперечить арифметиці, довіряй числам і стандартній ставці ПДВ 20%.",
].join(" ");

Найцікавіший — останній рядок. У реальних бланках підписи полів бувають помилкові: у моїй тестовій накладній рядок «Сума без ПДВ: 395,00» — це насправді сума ПДВ, а 1975,00, підписана «Всього», — якраз сума без ПДВ. Якщо не сказати моделі довіряти арифметиці, вона слухняно перепише помилкові підписи, і в базу поїде ПДВ 395 грн у полі «сума без ПДВ».

Як віддати моделі фото

Модель не має доступу до файлової системи: вона приймає або публічний URL, або data URI. Локальний файл читаємо самі й пакуємо в base64:

typescript
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, бо творчість тут не потрібна:

typescript
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 стискає картинку, і дрібний текст у таблиці зчитується з помилками. Ціна — суттєво більше вхідних токенів, але для документів це не опція, а необхідність.

Результат

Скан видаткової накладної № 3 від 04.01.2024: постачальник ТОВ «Едельвейс», покупець ТОВ «Орхідея», два товарні рядки, підсумки з ПДВ
Тестова накладна — типовий скан із таблицею і дрібним текстом

Ось що повертає gpt-4o-mini за один запит по цьому скану:

json
{
  "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: коштує дорожче, але дрібний текст читає помітно точніше.

Довіряй, але перевіряй

Модель може помилитися в будь-якій цифрі, тому результат я перевіряю не довірою, а арифметикою:

  • кількість × ціна = сума по кожному рядку;
  • сума рядків без ПДВ = підсумок «без ПДВ»;
  • підсумок без ПДВ + ПДВ = підсумок з ПДВ;
  • кількість найменувань у підсумку = кількість розпізнаних рядків.
typescript
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:

typescript
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 вихідних:

console
вартість запиту: $0.004520 (gpt-4o-mini, вхід 28336, вихід 449 токенів)
Менше пів цента за повністю структуровану накладну: з позиціями, сумами, ПДВ і перевіркою арифметики.

Висновки

  • Structured output зі схемою замість «поверни JSON»: модель фізично не може повернути щось поза контрактом.
  • nullable-поля — чесний спосіб сказати «цього немає в документі», без вигадок.
  • Правила домену — в system prompt: нормалізація чисел і дат, недовіра до помилкових підписів у бланку.
  • Арифметична перевірка результату — обов'язковий запобіжник для фінансових даних.
  • Дешевій vision-моделі можна довірити цифри, але не дрібний текст: для прізвищ і адрес у щільних сканах беріть модель старшу.

Увесь раннер — одна NestJS-команда на ~300 рядків: nest-commander, LangChain і жодного OCR-двигуна. Той самий підхід без змін працює і з Claude: vision і structured output є в обох провайдерів.