Обработка документов (Job API)

Жизненный цикл задачи

JOB_STATUS_NEW → JOB_STATUS_FILE_PROCESSING → JOB_STATUS_OCR → JOB_STATUS_LLM → JOB_STATUS_COMPLETE | JOB_STATUS_PARTIAL | JOB_STATUS_FAILED
  • JOB_STATUS_NEW — задача создана и поставлена в очередь.
  • JOB_STATUS_FILE_PROCESSING — файлы скачиваются, архивы распаковываются, форматы приводятся к обрабатываемому виду. Может перейти напрямую в JOB_STATUS_FAILED, если все источники недоступны или нарушен лимит файлов (причина — в поле error задачи).
  • JOB_STATUS_OCR — распознавание текста по каждому файлу.
  • JOB_STATUS_LLM — распознанный текст отправляется в модель с вашими промптами.
  • JOB_STATUS_COMPLETE — нет ни одной ошибки на стадиях OCR и LLM.
  • JOB_STATUS_PARTIAL — есть хотя бы один успешный ответ модели (LLM), но есть хотя бы одна ошибка на стадии OCR или LLM (смотрите ошибки на уровне файлов в результате), либо ответ, не соответствующий responseSchema (см. «Структурированный вывод»).
  • JOB_STATUS_FAILED — ошибки помешали хотя бы одному файлу получить успешный ответ модели: либо сбой на стадии загрузки/распаковки файлов (причина в поле error задачи, массивы ocr[]/llm[] пусты), либо ни один файл не дошёл до успешного результата OCR или LLM.

Обработка асинхронная: статус задачи опрашивается через GET /v1/jobs/{id} (polling), пока задача не дойдёт до терминального статуса.

Загрузка файлов

Источник для задачи можно задать двумя способами:

  1. Публичная ссылка — передайте URL в sourceUrls при создании задачи. ChunkChef скачает файл (таймаут скачивания — 30 с, до 3 редиректов).
  2. Прямая загрузка — загрузите файл и используйте полученную ссылку в sourceUrls:
    • POST /v1/jobs/upload (multipart/form-data) — загрузка одного файла по HTTP. Это отдельный HTTP-эндпоинт (не grpc-gateway). Ответ — JSON в snake_case: {"url": "…", "name": "…", "size_bytes": 12345}. Тело ошибки этого эндпоинта — {"code": <int>, "message": "…"} без массива details. Лимит размера задаётся конфигом (grpc.maxRecvMsgBytes; в текущем деплое — 20 MiB), не код-дефолт.
    • gRPC-метод Upload (client-streaming) — потоковая загрузка (лимит одного сообщения — 20 MiB).

Промпты и извлечение данных

Извлечение данных задаётся текстовыми промптами, а не схемой. В поле prompts вы передаёте массив инструкций. На LLM-стадии для каждого файла берётся его распознанный текст, при необходимости делится на части (чанки) и отправляется в модель вместе с вашим промптом. Ответ модели возвращается как текст по каждому файлу (и по каждой части, если файл делился). Опционально добавьте responseSchema — JSON Schema — чтобы ChunkChef проверял форму ответа после вызова модели; см. «Структурированный вывод» ниже.

Деление на части структурно-осведомлённое: текст режется по границам структуры своего формата (Markdown, HTML, XML или plain) — таблицы не рвутся посреди строк (а если не помещаются целиком, заголовок таблицы повторяется в каждой части), сохраняется контекст заголовков-секций. Размер части в токенах задаётся полем neural.chunkBudgetTokens (см. «Подключение модели»); если оно не задано, берётся консервативный дефолт сервиса. Каждая часть — это отдельный вызов модели с полной копией промпта и отдельная строка в llm[] (поля chunkIndex / chunkTotal). Сборку ответа из частей в порядке возрастания chunkIndex по умолчанию выполняет ваша сторона. Опционально ChunkChef может сделать эту сборку сам: включите merge.enabled, чтобы сервис объединил ответы по частям в один результат по всему документу — см. «Слияние результатов по частям» ниже.

Чтобы получить структурированные данные, прямо попросите об этом в промпте — например: «Верни результат в формате JSON со следующими полями: …». Валидность и форму JSON определяют ваш промпт и выбранная модель; ChunkChef не навязывает и не проверяет схему, если вы не подключите responseSchema (см. «Структурированный вывод» ниже).

Рекомендации к промптам:

  • перечисляйте нужные поля явно и однозначно;
  • задавайте формат вывода прямо в тексте промпта;
  • помните про лимит размера промпта — 64 KiB (см. «Лимиты»).

Примечание. <…> в шаблонах ниже — это места под вашу замену: ChunkChef не подставляет их автоматически, промпт уходит в модель ровно как есть. Структуру вывода держат ваш промпт и выбранная модель — серверной валидации по схеме нет, если не задан responseSchema (см. «Структурированный вывод»).

Качество промпта напрямую влияет на результат. Насколько хорошо отработает извлечение, зависит не только от выбранной модели, но и от промпта — зачастую даже сильнее. Расплывчатый промпт даёт расплывчатый результат даже на топовой модели; точный и хорошо структурированный — позволяет получать стабильный результат даже на небольших и недорогих моделях. Рассматривайте шаблон ниже как отправную точку, а не готовый промпт: возьмите его, опишите свой тип документа, свою задачу и нужный формат на выходе — и попросите сильную модель превратить его в промпт под ваш случай, сохранив структуру, но точно проработав правила, краевые случаи и валидацию вывода под ваши данные. Мета-промпт для этого — в конце раздела.

Пример: извлечение реквизитов из счёта/заказа/чека

Текст prompts[0]:

TASK
Extract structured fields from a single procurement/accounting document and return them strictly as JSON.

IMPORTANT
Your answer must contain ONLY JSON. Do not add any comments, explanations, or surrounding text before or after the JSON.

1. INPUT
The recognized text of a single document follows the "---" marker below (the OCR output is HTML). It is the only source. The document may be an <type: invoice / purchase order / receipt> and may contain stamps, signatures, and multi-row line-item tables. The text may be truncated or split into chunks — work only with the text you are given and never assume content you cannot see.

2. OUTPUT JSON FORMAT
Return a single JSON object matching this schema (the inline comments are explanatory — do not include them in the output):
{
  "doc_type": "string",   // one of: invoice | purchase_order | receipt | unknown
  "number": "string",
  "date": "string",       // ISO 8601: YYYY-MM-DD
  "supplier": {
    "name": "string",
    "tax_id": "string"    // e.g. US EIN or EU VAT ID, as printed
  },
  "items": [
    { "name": "string", "qty": number, "price": number, "amount": number }
  ],
  "total": number,
  "currency": "string"    // ISO 4217, e.g. USD, EUR
}

3. EXTRACTION RULES
- doc_type: classify from the title, headers, and content. If it is none of the listed types, set "unknown" and still fill any fields you can.
- number / date: the document's own number and issue date. Convert the date to ISO 8601 (YYYY-MM-DD).
- supplier: the selling/issuing party, not the buyer. tax_id: the supplier's tax identifier, exactly as printed.
- items: one object per line item, in document order. Keep "name" exactly as written, including specifications and units that identify the item.
- qty / price / amount / total: return as JSON numbers — strip thousands separators and currency symbols, use a dot as the decimal separator ("1,200.50" -> 1200.5).
- currency: ISO 4217 code. If only a symbol is present, map it ("$" -> "USD", "€" -> "EUR"). If it cannot be determined, use null.

4. PROCESSING REQUIREMENTS
- Use ONLY the provided document text. Do not add external knowledge or infer values that are not present.
- Do NOT guess, complete, or reformat values beyond the normalization explicitly required above.
- Field not found -> null for scalars (including supplier sub-fields), [] for "items". Never drop a schema key.
- Analyze the entire document, including tables and appendices. A reference to an external attachment is not a line item.
- Be literal and deterministic: the same input must always produce the same output.

5. RESPONSE FORMAT
- Return ONLY the valid JSON object described above.
- No markdown, no code fences, no text before or after the JSON.

REMEMBER
Your answer must start with "{" and end with "}". Nothing else. If the document is not one of the expected types, return the schema with "doc_type": "unknown" and whatever fields you could extract.

JSON-конверт (основной — на верифицированном Xiaomi mimo-v2-flash):

{
  "sourceUrls": ["<YOUR_FILE_URL>"],
  "title": "Invoice <number>",
  "prompts": ["<THE ENTIRE TEMPLATE ABOVE, AS A SINGLE STRING>"],
  "ocr": { "provider": "NEURAL_CLIENT_TYPE_MISTRAL", "model": "mistral-ocr-latest", "providerKey": "<YOUR_KEY>" },
  "neural": {
    "type": "NEURAL_CLIENT_TYPE_XIAOMI",
    "model": "mimo-v2-flash",
    "apiKey": "<YOUR_PROVIDER_KEY>",
    "reasoningEffort": "low"
  }
}

Заголовок: Authorization: Bearer <YOUR_CHUNKCHEF_KEY>.

Поле запросаЧто это«Переменная»
Authorizationваш API-ключ ChunkChefключ доступа к API
sourceUrls[]ссылки на файлыссылки на документы
prompts[0]весь шаблон одной строкойструктурированный промпт
neural.type / neural.modelпровайдер и модельмодель
neural.apiKeyключ провайдера (BYOK)ключ модели
neural.reasoningEffortопц. minimal/low/medium/highглубина рассуждений
ocr.providerOCR-провайдер (enum, напр. NEURAL_CLIENT_TYPE_MISTRAL)OCR-провайдер
ocr.modelидентификатор OCR-модели; обязательноеOCR-модель
ocr.providerKeyключ провайдера для OCR (BYOK); принимается только на вход, в ответах не возвращаетсяключ OCR
extractionModeстратегия извлечения текста: EXTRACTION_MODE_HYBRID (по умолчанию, «Оптимизированное гибридное извлечение») — конвертер сам решает, извлекать текст напрямую или запускать OCR, по каждому файлу; EXTRACTION_MODE_OCR_ALWAYS принудительно включает нейросетевой OCR для каждого файла; не задано / EXTRACTION_MODE_UNSPECIFIED — наследуется дефолт аккаунтаОпционально
responseSchemaопциональная JSON Schema (сырая строка), проверяющая JSON-ответ модели; самодостаточная схема, совместимая со strict-подмножеством (см. «Структурированный вывод»)Опционально

Ещё примеры (компактно). Та же механика — отличается только текст промпта и ожидаемая форма ответа в llm[].content:

  • Классификация. Промпт: «Determine the document type: invoice / contract / receipt / letter / other. Return a single word from the list, with no explanation.» Ответ: одно слово (напр. contract).
  • Условия договора. Промпт: «Extract: parties, subject, amount, term, and termination conditions. Return JSON matching the schema {parties[], subject, amount, term, termination}. Field not found → null.» Ответ: JSON по схеме.
  • Резюме. Промпт: «Summarize the document in 3–5 sentences. No bullet lists.» Ответ: связный текст.

Создание собственного промпта (мета-промпт)

Самый быстрый способ получить качественный промпт — поручить его сильной модели. Передайте ей мета-промпт ниже: вставьте наш пример как эталон структуры, желаемый формат вывода (JSON/CSV/Markdown), описание контекста и задачи — и получите готовый промпт для ChunkChef. Заполните блоки в квадратных скобках, остальное модель доработает сама.

You are a senior prompt engineer. Build a production-grade extraction prompt that will be sent to a document-processing model through the ChunkChef API. The model receives the OCR'd text (HTML) of a single document and must return data in a strict, machine-parseable format.

WHAT I'M GIVING YOU

1) REFERENCE PROMPT — the structure and style to follow. Preserve its section anatomy.
<<<REFERENCE_PROMPT
[paste the ChunkChef example prompt here]
REFERENCE_PROMPT

2) TARGET OUTPUT — the exact shape I need back: a JSON schema/sample, CSV columns, or Markdown layout.
<<<TARGET_OUTPUT
[paste your desired JSON / CSV / Markdown here]
TARGET_OUTPUT

3) DOMAIN & CONTEXT — what these documents are, where they come from, and their quirks (languages, layouts, stamps, tables, common OCR errors).
<<<CONTEXT
[describe your documents and domain]
CONTEXT

4) TASK — exactly what to extract or produce, plus the business rules, definitions, and edge cases that matter.
<<<TASK
[describe the task and rules]
TASK

5) OUTPUT FORMAT — one of: JSON | CSV | Markdown. Default: JSON.
<<<FORMAT
JSON
FORMAT

HOW TO BUILD THE PROMPT
1. Study the domain and task deeply before writing. Infer the edge cases a careful human reviewer would catch — ambiguous fields, duplicates, ranges, units, missing data, multi-row tables, appendices — and address each one explicitly.
2. Keep the REFERENCE PROMPT's anatomy: a one-line TASK, an IMPORTANT "only the target format" rule, then numbered sections (INPUT, OUTPUT FORMAT, EXTRACTION/PROCESSING RULES field by field, PROCESSING REQUIREMENTS, RESPONSE FORMAT), and a final REMEMBER reinforcement.
3. Make the output contract unambiguous for the chosen format:
   - JSON: give the full schema with types and nullability, mark required vs optional keys, forbid any text/markdown/code fences outside the JSON, and require the answer to start with "{" (or "[") and end with "}" (or "]").
   - CSV: fix the exact column order and header row, the delimiter, the quoting/escaping rule, and how empty values are written; one record per row, no prose.
   - Markdown: fix the exact headings/table columns and forbid any content outside that layout.
4. Pin the data discipline: use only the provided document text; never invent, guess, or reformat beyond the normalization you explicitly define; specify number, date, and unit normalization; define how "not found" is represented (null / empty / skipped) and how duplicates are handled; preserve source values verbatim where identity matters.
5. Account for ChunkChef specifics: the model sees one document's OCR'd HTML, possibly truncated or split into chunks; do not rely on any temperature setting — enforce determinism through wording ("be literal and deterministic"); the prompt is sent verbatim, so resolve every "<placeholder>" yourself.
6. Self-check before finishing: re-read the TARGET OUTPUT and confirm the prompt forces exactly that shape, that every field has a rule, and that a small, cheap model could follow it without guessing.

OUTPUT
Return ONLY the finished prompt, ready to paste into ChunkChef's "prompts" array — no explanation, no preamble, no code fences.

Подключение модели (BYOK)

LLM-стадия выполняется на вашем ключе провайдера. Конфигурация передаётся в объекте neural при создании задачи:

ПолеОбязательноеОписание
typeдапровайдер (см. список ниже)
modelдаидентификатор модели; передаётся провайдеру как есть
apiKeyдаваш ключ провайдера; принимается только на вход, в ответах не возвращается
reasoningEffortнетподсказка по глубине рассуждений; допустимые значения: minimal, low, medium, high (пусто = выкл). Невалидное значение → ошибка 400. Учёт зависит от провайдера/модели.
chunkBudgetTokensнетбюджет одного вызова модели в токенах: покрывает и промпт, и текст документа в одной части. 0/не задано → консервативный дефолт сервиса. Диапазон: 160002000000; значение вне диапазона → 400. Задавайте под контекстное окно вашей модели — его знаете только вы. В ответе всегда возвращается фактический эффективный бюджет (в том числе при использовании дефолта): сервис резервирует небольшой запас под служебные токены чата, поэтому возвращённое значение чуть меньше заданного.

Поддерживаемые провайдеры:

ПровайдерЗначение neural.type
OpenAINEURAL_CLIENT_TYPE_OPENAI
Anthropic (Claude)NEURAL_CLIENT_TYPE_CLAUDE
xAI (Grok)NEURAL_CLIENT_TYPE_GROK
TogetherNEURAL_CLIENT_TYPE_TOGETHER
DeepSeekNEURAL_CLIENT_TYPE_DEEPSEEK
XiaomiNEURAL_CLIENT_TYPE_XIAOMI
MistralNEURAL_CLIENT_TYPE_MISTRAL
OpenRouterNEURAL_CLIENT_TYPE_OPENROUTER

Через NEURAL_CLIENT_TYPE_OPENROUTER доступны модели множества вендоров, у которых нет прямой интеграции.

Вы платите провайдеру напрямую по его тарифу — ChunkChef не добавляет наценку на токены и OCR.

Режим извлечения («Оптимизированное гибридное извлечение»)

По умолчанию (EXTRACTION_MODE_HYBRID) конвертер сам решает по каждому файлу, извлекать текст напрямую или запускать OCR — это стратегия «Оптимизированное гибридное извлечение». Установите extractionMode в EXTRACTION_MODE_OCR_ALWAYS в задаче, чтобы принудительно включить нейросетевой OCR для каждого файла, даже если у него уже есть извлекаемый текстовый слой. Обратите внимание: в этом случае каждый файл становится отдельным вызовом OCR у провайдера — это увеличивает стоимость по сравнению с гибридным режимом. Значение extractionMode в самой задаче переопределяет дефолт аккаунта, который задаётся в личном кабинете в разделе «Настройки».

Структурированный вывод (responseSchema)

Передайте опциональный responseSchema — JSON Schema в виде сырой JSON-строки — при создании задачи, чтобы ChunkChef проверял форму JSON-ответа модели в дополнение к (а не вместо) описанию этой формы в промпте.

Схема должна быть самодостаточной: разрешены только внутренние ссылки #/$defs — никаких внешних $ref и никаких удалённых $schema/$id. Лимиты: не более 128 KiB, глубина вложенности 64, всего 10000 узлов. Невалидная или превышающая лимиты схема отклоняется с 400 при создании задачи, до обработки любого файла. Для OpenAI и Grok структурированный вывод работает нативно и требует strict-подмножества ChunkChef: каждое свойство должно быть в required (опциональность выражается через type: [..., "null"], а не пропуском поля), и additionalProperties: false для каждого объекта. Если написать схему в этом strict-подмножестве, она становится переносимой — одна и та же схема работает без изменений у всех поддерживаемых провайдеров. Схема, которая проходит проверки ChunkChef при создании задачи, но не совместима со strict-подмножеством для OpenAI/Grok, не получает 400 при создании и не проходит через цикл schemaValid/schemaErrors — вместо этого соответствующая строка llm[] завершается со статусом JOB_LLM_STATUS_FAILED и ошибкой провайдера, поскольку соответствие strict-подмножеству проверяет сам провайдер, а не создание задачи в ChunkChef.

Два поля в каждой строке llm[] сообщают результат (см. «Справочник API»):

  • schemaValid — соответствует ли content этой строки схеме responseSchema. Присутствует только если файл не делился на части (chunkTotal = 1); для файлов с несколькими частями поле отсутствует — схема всё равно отправляется с каждым вызовом на часть, но по-частичные вердикты пока не сводятся в единый вердикт по всему документу.
  • schemaErrors — нарушения соответствия схеме, когда schemaValid равно false (до 10 штук, каждое до 512 байт).

Если ответ хотя бы в одной строке не соответствует схеме, задача завершается статусом JOB_STATUS_PARTIAL вместо JOB_STATUS_COMPLETEcontent всё равно возвращается как есть, просто с пометкой. Для провайдеров, для которых ChunkChef эмулирует структурированный вывод (DeepSeek, Xiaomi), несоответствующий первый ответ получает одну автоматическую повторную попытку исправления перед оценкой.

Слияние результатов по частям (merge)

Когда документ делится на части (см. «Промпты и извлечение данных» выше), каждая часть получает отдельный вызов модели и отдельную строку в llm[] — объединение этих кусков в один ответ по умолчанию остаётся на вашей стороне. Включите merge при создании задачи, и ChunkChef сделает это объединение сам, на сервере: в результате появляется массив merged[] с одним ответом по всему документу (или по файлу), собранным из ответов по частям.

Когда это включать. Если вы извлекаете структурированный JSON из документа, который достаточно длинный, чтобы делиться на несколько частей — например, из многостраничного счёта, чьи позиции распределены между страницей 1 и страницей 2, — merge избавляет вас от написания кода сборки: ChunkChef объединяет поля JSON, конкатенирует и дедуплицирует массивы, а также разрешает поля, которые расходятся между частями.

Включение:

{ "merge": { "enabled": true } }

Все остальные поля merge.* опциональны и имеют безопасные значения по умолчанию (полный список — в «Справочнике API», раздел «Опции слияния»).

Пример: счёт на двух страницах

Представьте счёт на 2 страницах, распознанный в текст, достаточно длинный, чтобы разделиться на 2 части — страница 1 попадает в часть 0, страница 2 — в часть 1. Ваш промпт просит JSON: { "supplier": "...", "items": [...], "total": "..." }. Без merge вы получаете два отдельных ответа llm[], по одному на часть, и объединяете их сами. С включённым merge ChunkChef объединяет их в один JSON-объект: массивы items из обеих частей конкатенируются в один, а supplier/total берутся из той части, где они реально указаны. Если часть 0 и часть 1 сообщают разный total для одного и того же документа (например, OCR продублировал строку промежуточного итога, или модель неверно распознала число) — это настоящий конфликт, см. «Разрешение конфликтующих значений» ниже.

Область слияния: один ответ на файл или один на всю задачу (merge.scope)

merge.scope определяет, сколько объединяется в одну запись merged[]:

  • MERGE_SCOPE_FILE (по умолчанию) — части сливаются отдельно в рамках каждого файла: одна запись merged[] на файл, с merged[].file, равным URL этого файла.
  • MERGE_SCOPE_JOB — части сливаются по всем файлам задачи в один ответ: одна запись merged[] с пустым ("") merged[].file. Используйте этот режим, когда загруженные файлы — это страницы или части одного логического документа (например, заказ на закупку, разбитый на несколько исходных файлов), и вам нужен один объединённый результат вместо результата по каждому файлу.

Разрешение конфликтующих значений (merge.conflictPolicy)

При слиянии JSON две части могут разойтись в значении одного и того же поля — одна сообщает total: 1500, другая total: 1520 для одного и того же счёта. merge.conflictPolicy определяет, какое значение победит:

  • MERGE_CONFLICT_POLICY_FIRST_NON_NULL (по умолчанию) — побеждает первое не-null значение в порядке частей.
  • MERGE_CONFLICT_POLICY_MAJORITY — побеждает значение, встречающееся чаще всего; если частей меньше 3 или ни одно значение не набирает строгого большинства, ChunkChef откатывается к FIRST_NON_NULL.

В любом случае каждое такое расхождение фиксируется в merged[].conflicts[] — поле field, конкурирующие values и то, из какого файла/части взято каждое значение, — так что несовпадающий total не замалчивается. Проверяйте conflicts[], когда нужно понять, можно ли доверять победившему значению, или стоит проверить его вручную.

Два вида дедупликации

Merge удаляет дублирующийся контент двумя разными способами:

  1. Техническая дедупликация — всегда включена, настройка не нужна. Части одного файла обычно немного перекрываются на границе (одна и та же строка таблицы оказывается в конце одной части и в начале следующей); merge автоматически обнаруживает и убирает это перекрытие. Это безопасное поведение по умолчанию — оно никогда не трогает контент за пределами такого перекрытия.
  2. Дедупликация по содержимому — опционально, через merge.dedupeBy. При scope=job одна и та же логическая запись может законно встречаться в нескольких файлах (например, одна и та же позиция повторяется в двух связанных документах), и такое повторение может быть намеренным — поэтому ChunkChef не трогает его, пока вы не попросите. Укажите в merge.dedupeBy имена JSON-полей, однозначно идентифицирующих запись (например, ["invoiceNumber", "lineNo"]), и ChunkChef схлопнет записи, совпадающие по этим полям, оставив первое вхождение. Оставьте merge.dedupeBy пустым — значение по умолчанию, — чтобы работала только техническая дедупликация, и ничего сверх того.

merge.dedupeBy работает только с JSON-выводом и требует заданного responseSchema — имена полей ищутся как обычные ключи в разобранном JSON-объекте. Не более 32 имён полей; при превышении запрос отклоняется с 400 при создании задачи.

Оба вида удаления считаются в результате отдельно, чтобы их можно было отличить:

  • merged[].dedupeRemovedTechnical — записи, удалённые автоматической дедупликацией по перекрытию границ.
  • merged[].dedupeRemovedContent — записи, удалённые благодаря вашим полям dedupeBy.

Чтение результата: merged[] против сырых llm[]

Когда merge включён, в результате появляется merged[] рядом с существующим llm[]:

  • merged[] — объединённый на сервере ответ по всему документу (или файлу). Читайте его как основной результат.
  • llm[] — не меняется: по-прежнему одна строка на часть. Он остаётся доступным, чтобы вы могли посмотреть, что именно вернул вызов модели по каждой части — полезно при разборе конфликта.

Если вы задали responseSchema, объединённый JSON-объект проверяется по ней, и merged[].schemaValid сообщает результат — так же, как сегодня llm[].schemaValid для файла из одной части.

Если часть не удалось слить (merged[].mergeIncomplete)

Слияние выполняется по принципу best-effort: если ответ одной части не разбирается или структура документа распределена между частями так, что merge не может безопасно их объединить, сырое содержимое этой части всё равно включается — добавляется, а не сливается, — и merged[].mergeIncomplete устанавливается в true, а затронутые части перечисляются в merged[].incompleteChunks. Остальная часть слияния при этом завершается как обычно; проверяйте этот флаг, когда нужно понять, полностью ли чист объединённый результат или собран частично.

Консенсус по нескольким запускам (consensus)

Для задачи со схемой (задан responseSchema) можно попросить ChunkChef прогнать одну и ту же часть через модель несколько раз и проголосовать по каждому полю ответа, вместо того чтобы доверять единственному прогону. Включается полем consensus.mode при создании задачи: ChunkChef выполняет часть k раз, берёт значение большинства (относительного) для каждого поля и сообщает и согласованный ответ, и каждое поле, в котором модель колебалась — сигнал доверия поверх вашего обычного BYOK-вызова, ценой k-кратной стоимости токенов на вашем же ключе. Поскольку прогоны выполняются последовательно, консенсус также увеличивает время обработки — задача может выполняться до k раз дольше.

Требует responseSchema. Консенсусу нужна схема, чтобы понимать, что такое «поле» — без неё голосовать не по чему. Если consensus.mode задан без responseSchema, запрос отклоняется с 400 при создании задачи.

Включение:

{ "responseSchema": "...", "consensus": { "mode": "CONSENSUS_MODE_THREE_RUNS" } }

Режимы

Название режима несёт в себе множитель по токенам — отдельного числа «прогонов» задавать не нужно:

РежимПрогоновПримечание
CONSENSUS_MODE_TWO_RUNS2самый дешёвый — проверка на нестабильность, а не надёжное голосование (см. оговорки ниже)
CONSENSUS_MODE_THREE_RUNS3рекомендуемый по умолчанию — полноценное голосование относительным большинством
CONSENSUS_MODE_FIVE_RUNS5максимальная тщательность, самая высокая стоимость

Если consensus не задан (либо CONSENSUS_MODE_UNSPECIFIED), консенсус выключен — поведение побайтово идентично задаче без консенсуса.

Чтение результата: minAgreement и disagreements

Каждая строка llm[] получает объект consensus (полный список полей — см. «Справочник API»). В первую очередь стоит смотреть на два значения:

  • minAgreement — наименьшее согласие по полям в этой строке, в виде доли (1.0 = все проголосованные поля совпали во всех прогонах). Значение 0 — зарезервированный признак того, что реального голосования не было (менее 2 прогонов, участвовавших в голосовании, либо голосование деградировало) — читайте его как «консенсус недоступен для этой части», а не как «0% согласия».
  • disagreements[] — по одной записи на поле (или массив), по которому прогоны не пришли к полному согласию, от наименьшего согласия к наибольшему. В каждой записи перечислены конкурирующие variants[]: значение, сколько прогонов его дали и выиграл ли он (included) — то есть победил в голосовании и присутствует в согласованном ответе. У варианта может быть included: true даже когда его значение — null: победивший null означает, что ключ поля опущен в согласованном JSON, а не что в нём буквально стоит null.

Что важно знать

  • CONSENSUS_MODE_TWO_RUNS — это проверка, а не голосование. При k=2 расхождение по скалярному полю всегда делится 1 на 1 (согласие 0.5), а для массивов ничего не отфильтровывается — в согласованный ответ попадает каждый элемент, указанный хотя бы в одном из двух прогонов. Два прогона показывают сам факт, что модель колебалась; реально отсеивают меньшинство только три прогона и больше.
  • Голосование относительным большинством может отбросить сущность, с наличием которой согласны все прогоны. Если элемент массива присутствует в каждом прогоне, но с одной расходящейся ячейкой (например, позицию извлекли все прогоны, но один неверно распознал число), версия каждого прогона считается отдельным каноническим значением — при k≥3 на каждую приходится 1 из k, что ниже порога большинства, и элемент может быть исключён из согласованного массива, даже если все прогоны согласны, что он там должен быть. disagreements[] всё равно показывает все варианты, так что исходные данные никуда не пропадают, даже если их нет в согласованном ответе — проверяйте это поле, если массив выглядит короче, чем ожидалось.
  • Слишком большой ответ считается не участвующим в голосовании, а не ошибкой. Ответ прогона, который разбирается как валидный JSON, но превышает внутренние лимиты разбора ChunkChef, исключается из голосования; голосование продолжается по оставшимся прогонам — так же, как и ответ, вернувший не-JSON текст.
  • Консенсус и merge — два независимых голосования, а не одно. При включении обоих сначала происходит внутреннее голосование по каждой части (k прогонов → один согласованный ответ на часть), а затем, если merge.conflictPolicy равен MERGE_CONFLICT_POLICY_MAJORITY, слияние голосует ещё раз — уже между согласованными ответами частей. Между двумя голосованиями ничего не переносится: поле, которое уже разрешил консенсус на уровне части, попадает в слияние как обычное устоявшееся значение — так же, как ответ любой другой части.

Идемпотентные повторы

Чтобы безопасно повторять создание задачи, сгенерируйте один idempotencyKey (UUID) и переиспользуйте его в повторных попытках одного и того же запроса:

POST /v1/jobs
{ "sourceUrls": ["..."], "ocr": { ... }, "neural": { ... }, "idempotencyKey": "3f1c…" }

Повторный запрос с тем же ключом и идентичными параметрами вернёт исходную задачу; смена любого параметра при том же ключе вернёт 400. Для нового задания используйте новый ключ.

Вебхуки

Вебхуки позволяют не опрашивать API в цикле: сервис сам уведомит ваш эндпоинт, когда задача завершится. Это опциональная функция — если поля не заданы, поведение API не меняется.

Настройка

При создании задачи (POST /v1/jobs) передайте одно или оба необязательных поля:

ПолеТипОписание
webhookUrlstringАбсолютный URL вашего эндпоинта (http:// или https://). Принимается только на вход — в ответах не возвращается.
webhookSecretstringОпциональный секрет для HMAC-подписи. Принимается только на вход — в ответах не возвращается; хранится в зашифрованном виде и удаляется вместе с задачей (см. «Безопасность и данные»).

Пример:

{
  "sourceUrls": ["https://example.com/invoice.pdf"],
  "prompts": ["Extract the total."],
  "ocr": { "provider": "NEURAL_CLIENT_TYPE_MISTRAL", "model": "mistral-ocr-latest", "providerKey": "..." },
  "neural": { "type": "NEURAL_CLIENT_TYPE_XIAOMI", "model": "mimo-v2-flash", "apiKey": "..." },
  "webhookUrl": "https://your-service.example.com/hooks/ChunkChef",
  "webhookSecret": "my-secret-value"
}

Когда срабатывает вебхук

Один раз на задачу — при первом переходе в терминальный статус (JOB_STATUS_COMPLETE, JOB_STATUS_PARTIAL или JOB_STATUS_FAILED). Сама доставка — at-least-once (возможны повторы при сбоях), см. «Гарантии доставки». Результат обработки вебхук не несёт — он лишь сигнализирует о завершении. Полный результат заберите обычным запросом GET /v1/jobs/{id}/result.

Тело запроса

Сервис отправляет POST на ваш webhookUrl с заголовком Content-Type: application/json и JSON-телом:

{
  "job_id":      "15b07304-...",
  "account_id":  "a1b2c3d4-...",
  "status":      "complete",
  "finished_at": "2026-06-24T12:34:56Z"
}
ПолеТипОписание
job_idstringUUID задачи
account_idstring (идентификатор аккаунта)Идентификатор аккаунта
statusstringОдин из: complete, partial, failed
finished_atstringВремя завершения задачи в формате RFC3339 (UTC)

Проверка подписи

Если webhookSecret задан, каждый запрос содержит два дополнительных заголовка:

ЗаголовокПример значенияОписание
X-Chunkchef-Timestamp1750765200Unix-время отправки (секунды)
X-Chunkchef-Signaturesha256=a3f4...HMAC-SHA256 подпись

Алгоритм подписи:

signature = "sha256=" + hex( hmac_sha256(secret, "<timestamp>.<body>") )

где <timestamp> — строковое представление Unix-времени из X-Chunkchef-Timestamp, <body> — сырое тело запроса (байты как есть), точка — разделитель. hex — в нижнем регистре; secret используется как UTF-8-байты.

Как проверить на вашей стороне:

  1. Извлеките значение X-Chunkchef-Timestamp.
  2. Вычислите hmac_sha256(secret, "<X-Chunkchef-Timestamp value from step 1>.<raw request body>"), возьмите hex в нижнем регистре и добавьте префикс sha256=.
  3. Сравните с X-Chunkchef-Signature сравнением, не зависящим от времени выполнения (constant-time) (функции типа hmac.Equal / crypto/subtle.ConstantTimeCompare).
  4. Отклоните запрос, если метка времени слишком старая (рекомендуемый допуск — 5 минут).

Если webhookSecret не задан, заголовки X-Chunkchef-Timestamp и X-Chunkchef-Signature не отправляются.

Политика повторов

При недоступности или ошибке вашего эндпоинта сервис повторяет доставку по расписанию:

ПопыткаЗадержка перед следующей
1 → 21 минута
2 → 35 минут
3 → 415 минут
4 → 530 минут
5 → 630 минут
6— (финальная, после неё доставка помечается неудачной)

Итого: максимум 6 попыток.

Постоянные ошибки (4xx кроме 408/429, недопустимый URL, SSRF-блокировка) не повторяются: доставка сразу помечается неудачной. Успешным считается ответ с кодом 2xx.

Гарантии доставки

Доставка — at-least-once: в большинстве случаев вебхук придёт ровно один раз, но при сбоях возможна повторная доставка. Дедуплицируйте события по job_id на своей стороне.