From 19dd71bd935ea7f571bf488f46843fec73cddbe4 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Wed, 6 May 2026 15:17:11 +0200 Subject: [PATCH] feat(api): MistralOcrProvider + multipart upload sur /invoices/upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MistralOcrProvider (app/services/ocr/mistral_ocr_provider.ts) : - Pipeline 2 étapes : POST /v1/ocr (mistral-ocr-latest) → markdown structuré, puis POST /v1/chat/completions (mistral-large-latest) avec response_format json_schema strict pour extraire les champs typés (clientName/Email, numero, amountTtcCents, issueDate, dueDate) + un objet `_conf` pour la confiance par champ. - Télécharge le PDF depuis Drive (MinIO en dev) via getArrayBuffer, encode en base64 pour le data URI. - Throw clair si storageKey null (incompatible avec le mode JSON {filenames}). - Throw au constructor si MISTRAL_API_KEY manquante. getOcrProvider() retourne maintenant vraiment Mistral quand OCR_PROVIDER=mistral (plus de fallback silencieux sur mock). Multipart upload sur POST /invoices/upload : - Détecte Content-Type. Si multipart/form-data : itère sur `files[]`, valide ext (pdf/png/jpg/jpeg) + size (10mb), upload chaque fichier vers `import-drafts//.` via @adonisjs/drive, puis appelle createImportBatch avec sources [{filename, storageKey}]. - Si JSON : route compat conservée pour le mode démo. Refactor service import_batch : - Nouvelle fonction createImportBatch(orgId, sources) générique - createImportBatchFromFilenames() devient un wrapper compat (storageKey null) - OCR exécuté HORS transaction (calls réseau Mistral lents — 3-8s par PDF — pas de raison de tenir un lock PG) Bruno : - 06-Imports/02 Upload (multipart Mistral).bru — nouveau, body multipart-form avec @file() à sélectionner. Doc : setup .env, where to find files in MinIO console, latence Mistral. - Renumérote 03/04/05/06 (Get batch / Validate / Skip / Cancel). - Met à jour 01 Upload (mock) doc pour pointer vers 02 pour le vrai OCR. Pour tester : 1. .env → OCR_PROVIDER=mistral + MISTRAL_API_KEY=... 2. Restart pnpm dev:api 3. Bruno → Imports → 02 Upload (multipart Mistral) → sélectionne un PDF 4. Bruno → Imports → 03 Get batch (drafts ont pdfStorageKey + extracted depuis l'OCR) --- .../controllers/import_batches_controller.ts | 69 ++++++- apps/api/app/services/import_batch.ts | 89 ++++++--- apps/api/app/services/ocr/index.ts | 12 +- .../app/services/ocr/mistral_ocr_provider.ts | 184 ++++++++++++++++++ bruno/06-Imports/01 Upload (mock).bru | 11 +- .../02 Upload (multipart Mistral).bru | 82 ++++++++ .../{02 Get batch.bru => 03 Get batch.bru} | 4 +- ...lidate draft.bru => 04 Validate draft.bru} | 4 +- .../{04 Skip draft.bru => 05 Skip draft.bru} | 4 +- ...5 Cancel batch.bru => 06 Cancel batch.bru} | 4 +- 10 files changed, 412 insertions(+), 51 deletions(-) create mode 100644 apps/api/app/services/ocr/mistral_ocr_provider.ts create mode 100644 bruno/06-Imports/02 Upload (multipart Mistral).bru rename bruno/06-Imports/{02 Get batch.bru => 03 Get batch.bru} (95%) rename bruno/06-Imports/{03 Validate draft.bru => 04 Validate draft.bru} (97%) rename bruno/06-Imports/{04 Skip draft.bru => 05 Skip draft.bru} (96%) rename bruno/06-Imports/{05 Cancel batch.bru => 06 Cancel batch.bru} (94%) diff --git a/apps/api/app/controllers/import_batches_controller.ts b/apps/api/app/controllers/import_batches_controller.ts index f0515a6..d78ca08 100644 --- a/apps/api/app/controllers/import_batches_controller.ts +++ b/apps/api/app/controllers/import_batches_controller.ts @@ -10,8 +10,15 @@ import { validateDraftValidator, } from '#validators/import_batch' import { resolveClient } from '#services/resolve_client' -import { createImportBatchFromFilenames } from '#services/import_batch' +import { + createImportBatch, + createImportBatchFromFilenames, + type ImportSource, +} from '#services/import_batch' import { recordActivity } from '#services/activity_recorder' +import drive from '@adonisjs/drive/services/main' +import { createReadStream } from 'node:fs' +import { randomUUID } from 'node:crypto' import type { HttpContext } from '@adonisjs/core/http' import { Exception } from '@adonisjs/core/exceptions' import db from '@adonisjs/lucid/services/db' @@ -45,16 +52,64 @@ export default class ImportBatchesController { /** * POST /invoices/upload — démarre un batch OCR. * - * V1 mock : accepte un body JSON `{ filenames: [...] }` (pas de fichier - * réel). Le service appelle le MockOcrProvider qui invente des champs - * plausibles. Quand on aura Mistral, on basculera sur multipart. + * Deux modes selon Content-Type : + * - **multipart/form-data** : champ `files[]` avec les vrais PDFs. + * Stockage MinIO + OCR (mock OU mistral selon OCR_PROVIDER). + * - **application/json** : `{ filenames: string[] }` (V1 démo). + * Aucun fichier stocké → ne marche QU'AVEC OCR_PROVIDER=mock. */ - async upload({ auth, request, response }: HttpContext) { + async upload(ctx: HttpContext) { + const { auth, request, response } = ctx const organizationId = requireOrgId(auth) + + const isMultipart = (request.header('content-type') ?? '').startsWith('multipart/') + + if (isMultipart) { + const files = request.files('files', { + size: '10mb', + extnames: ['pdf', 'png', 'jpg', 'jpeg'], + }) + if (files.length === 0) { + return response.status(422).json({ + errors: [ + { code: 'validation_failed', field: 'files', message: 'Au moins un fichier requis' }, + ], + }) + } + + // Upload vers Drive (MinIO) AVANT l'OCR — l'OCR Mistral télécharge + // depuis Drive donc il faut que le fichier soit déjà posé. + // Clé : import-drafts//. — pas de batchId + // dans la clé car le batch est créé après. + const sources: ImportSource[] = [] + for (const f of files) { + if (!f.isValid || !f.tmpPath || !f.extname) { + return response.status(422).json({ + errors: [ + { + code: 'validation_failed', + field: 'files', + message: f.errors?.[0]?.message ?? 'Fichier invalide', + }, + ], + }) + } + const draftKey = randomUUID() + const storageKey = `import-drafts/${organizationId}/${draftKey}.${f.extname}` + await drive.use().putStream(storageKey, createReadStream(f.tmpPath)) + sources.push({ + filename: f.clientName ?? `${draftKey}.${f.extname}`, + storageKey, + }) + } + + const batch = await createImportBatch(organizationId, sources) + return response.status(201).json({ data: serializeBatch(batch) }) + } + + // Mode JSON — compat V1 démo. const { filenames } = await request.validateUsing(uploadValidator) - const batch = await createImportBatchFromFilenames(organizationId, filenames) - return response.status(201).json({ data: serializeBatch(batch) }) } diff --git a/apps/api/app/services/import_batch.ts b/apps/api/app/services/import_batch.ts index f422457..0cd852b 100644 --- a/apps/api/app/services/import_batch.ts +++ b/apps/api/app/services/import_batch.ts @@ -29,10 +29,17 @@ export type DraftConfidence = Partial<{ }> /** - * Compose les champs `extracted` (DraftFields) + `confidence` à partir - * du résultat OCR brut. Tente un match client immédiat (case-insensitive - * sur le nom) pour pré-remplir clientId — l'utilisateur n'a rien à faire - * dans le combobox si ça matche. + * Une "source" de draft : un filename + (optionnellement) une storageKey + * MinIO du PDF stocké. Mock OCR ignore storageKey, Mistral l'exige. + */ +export type ImportSource = { + filename: string + storageKey: string | null +} + +/** + * Compose `extracted` + `confidence` à partir du résultat OCR. Tente un + * match client immédiat (case-insensitive) pour pré-remplir `clientId`. */ async function buildDraftFromOcr( organizationId: string, @@ -68,42 +75,64 @@ async function buildDraftFromOcr( } /** - * Crée un batch + N drafts à partir d'une liste de filenames (V1 mock). - * Quand on aura le vrai upload multipart + MinIO, cette fonction prendra - * `Array<{ filename, storageKey }>` à la place. + * Crée un batch + N drafts à partir de N sources (filename + storageKey). + * Le provider OCR (mock ou mistral) est résolu à l'intérieur. + * + * - Mock : storageKey=null OK, extraction depuis filename + * - Mistral : storageKey requis, extraction depuis le PDF stocké */ -export async function createImportBatchFromFilenames( +export async function createImportBatch( organizationId: string, - filenames: string[] + sources: ImportSource[] ): Promise { const ocr = getOcrProvider() - // Plan par défaut = premier `is_default` de l'org (provisionné au signup). + // Plan par défaut = premier is_default de l'org (provisionné au signup). const defaultPlan = await Plan.query() .where('organization_id', organizationId) .where('is_default', true) .orderBy('name', 'asc') .first() + // OCR fait HORS transaction (calls réseau lents, on ne tient pas de lock + // PG pendant). Si l'OCR échoue, l'erreur remonte avant le INSERT. + type DraftPayload = { + filename: string + storageKey: string | null + extracted: DraftFields + edited: DraftFields + confidence: DraftConfidence + } + const drafts: DraftPayload[] = [] + + for (const src of sources) { + const result = await ocr.extract(src) + const { extracted, confidence } = await buildDraftFromOcr( + organizationId, + result, + defaultPlan?.id ?? null + ) + drafts.push({ + filename: src.filename, + storageKey: src.storageKey, + extracted, + edited: { ...extracted }, + confidence, + }) + } + return db.transaction(async (trx) => { const batch = await ImportBatch.create({ organizationId }, { client: trx }) - for (const filename of filenames) { - const result = await ocr.extract({ storageKey: null, filename }) - const { extracted, confidence } = await buildDraftFromOcr( - organizationId, - result, - defaultPlan?.id ?? null - ) - + for (const d of drafts) { await ImportDraft.create( { batchId: batch.id, - filename, - pdfStorageKey: null, - extracted, - edited: { ...extracted }, - confidence, + filename: d.filename, + pdfStorageKey: d.storageKey, + extracted: d.extracted, + edited: d.edited, + confidence: d.confidence, status: 'pending', invoiceId: null, }, @@ -115,3 +144,17 @@ export async function createImportBatchFromFilenames( return batch }) } + +/** + * Wrapper compat : V1 mock JSON `{filenames}` → sources avec storageKey null. + * @deprecated Préférer `createImportBatch` avec sources explicites. + */ +export async function createImportBatchFromFilenames( + organizationId: string, + filenames: string[] +): Promise { + return createImportBatch( + organizationId, + filenames.map((filename) => ({ filename, storageKey: null })) + ) +} diff --git a/apps/api/app/services/ocr/index.ts b/apps/api/app/services/ocr/index.ts index f3a155e..9e8f28d 100644 --- a/apps/api/app/services/ocr/index.ts +++ b/apps/api/app/services/ocr/index.ts @@ -1,22 +1,20 @@ import env from '#start/env' import type { OcrProvider } from '#services/ocr/ocr_provider' import { MockOcrProvider } from '#services/ocr/mock_ocr_provider' +import { MistralOcrProvider } from '#services/ocr/mistral_ocr_provider' /** * Résout l'implémentation OCR à utiliser selon OCR_PROVIDER. * * - `mock` (default) : MockOcrProvider, données plausibles depuis filename. - * - `mistral` : à brancher (cf. ADR-020). Pour l'instant on fallback sur mock - * avec un warning pour ne pas casser le boot quand la clé n'est pas posée. + * Compatible avec /invoices/upload en mode JSON `{filenames}`. + * - `mistral` : MistralOcrProvider. Nécessite un PDF stocké (multipart + * upload) + MISTRAL_API_KEY. Pas compatible avec le mode JSON. */ export function getOcrProvider(): OcrProvider { const provider = env.get('OCR_PROVIDER', 'mock') if (provider === 'mistral') { - // TODO: implémenter MistralOcrProvider quand la clé API est dispo. - // En attendant, on log et on fallback sur mock. - console.warn( - '[ocr] OCR_PROVIDER=mistral mais MistralOcrProvider pas implémenté — fallback sur mock' - ) + return new MistralOcrProvider() } return new MockOcrProvider() } diff --git a/apps/api/app/services/ocr/mistral_ocr_provider.ts b/apps/api/app/services/ocr/mistral_ocr_provider.ts new file mode 100644 index 0000000..52c9be5 --- /dev/null +++ b/apps/api/app/services/ocr/mistral_ocr_provider.ts @@ -0,0 +1,184 @@ +import drive from '@adonisjs/drive/services/main' +import env from '#start/env' +import type { OcrProvider, OcrResult } from '#services/ocr/ocr_provider' + +const MISTRAL_API = 'https://api.mistral.ai/v1' +// Modèle OCR dédié de Mistral — extrait le texte structuré d'un doc. +const OCR_MODEL = 'mistral-ocr-latest' +// Modèle chat pour la 2e étape (markdown → JSON typé via json_schema strict). +const EXTRACTION_MODEL = 'mistral-large-latest' + +const SYSTEM_PROMPT = `Tu es un extracteur de factures françaises B2B. +Tu reçois le markdown d'une facture (issu d'une OCR) et tu retournes un +JSON strict avec les champs demandés. + +Règles : +- amountTtcCents : montant TTC en centimes (entier). Pas le HT. +- issueDate / dueDate : ISO 8601 datetime UTC à 09:00 (ex. "2026-04-15T09:00:00.000Z"). +- clientEmail : null si absent ou illisible (pas d'invention). +- numero : tel qu'imprimé sur la facture. +- Si un champ est ambigu, mets une confiance basse (0.3–0.6).` + +/** + * Provider OCR Mistral. Pipeline en 2 étapes : + * 1. POST /v1/ocr avec le PDF en data URI base64 → markdown structuré + * 2. POST /v1/chat/completions avec le markdown + json_schema strict → + * extraction typée des champs + * + * Nécessite un PDF réel (storageKey non null). Pour le dev sans PDF, + * utiliser OCR_PROVIDER=mock. + */ +export class MistralOcrProvider implements OcrProvider { + private apiKey: string + + constructor() { + const key = env.get('MISTRAL_API_KEY', '') + if (!key) { + throw new Error( + 'MISTRAL_API_KEY manquante. Posez la dans .env ou bascule OCR_PROVIDER=mock.' + ) + } + this.apiKey = key + } + + async extract(input: { + storageKey: string | null + filename: string + }): Promise { + if (!input.storageKey) { + throw new Error( + `MistralOcrProvider exige un PDF stocké (storageKey). Filename "${input.filename}" reçu sans storageKey — utiliser OCR_PROVIDER=mock pour les uploads sans fichier réel.` + ) + } + + // 1. Télécharge le PDF depuis Drive (MinIO en dev) puis encode en base64. + const buffer = await this.downloadAsBuffer(input.storageKey) + const dataUri = `data:application/pdf;base64,${buffer.toString('base64')}` + + // 2. OCR → markdown + const ocrJson = await this.postJson('/ocr', { + model: OCR_MODEL, + document: { type: 'document_url', document_url: dataUri }, + }) + const markdown = (ocrJson?.pages ?? []) + .map((p: { markdown?: string }) => p.markdown ?? '') + .join('\n\n') + .trim() + + if (!markdown) { + throw new Error("Mistral OCR n'a retourné aucun texte exploitable") + } + + // 3. Extraction structurée via chat avec json_schema strict. + const extracted = await this.extractFields(markdown) + + return { + fields: { + clientName: { value: extracted.clientName, confidence: extracted._conf.clientName }, + clientEmail: { value: extracted.clientEmail, confidence: extracted._conf.clientEmail }, + numero: { value: extracted.numero, confidence: extracted._conf.numero }, + amountTtcCents: { + value: extracted.amountTtcCents, + confidence: extracted._conf.amountTtcCents, + }, + issueDate: { value: extracted.issueDate, confidence: extracted._conf.issueDate }, + dueDate: { value: extracted.dueDate, confidence: extracted._conf.dueDate }, + }, + rawProviderResponse: { ocr: ocrJson, extracted }, + } + } + + private async downloadAsBuffer(storageKey: string): Promise { + const arr = await drive.use().getArrayBuffer(storageKey) + return Buffer.from(arr) + } + + private async postJson(path: string, body: unknown): Promise { + const res = await fetch(`${MISTRAL_API}${path}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }) + if (!res.ok) { + const text = await res.text() + throw new Error(`Mistral ${path} → HTTP ${res.status}: ${text}`) + } + return res.json() + } + + private async extractFields(markdown: string): Promise<{ + clientName: string + clientEmail: string | null + numero: string + amountTtcCents: number + issueDate: string + dueDate: string + _conf: Record + }> { + const json = await this.postJson('/chat/completions', { + model: EXTRACTION_MODEL, + messages: [ + { role: 'system', content: SYSTEM_PROMPT }, + { role: 'user', content: markdown }, + ], + response_format: { + type: 'json_schema', + json_schema: { + name: 'invoice_fields', + strict: true, + schema: { + type: 'object', + additionalProperties: false, + properties: { + clientName: { type: 'string' }, + clientEmail: { type: ['string', 'null'] }, + numero: { type: 'string' }, + amountTtcCents: { type: 'integer' }, + issueDate: { type: 'string' }, + dueDate: { type: 'string' }, + _conf: { + type: 'object', + additionalProperties: false, + properties: { + clientName: { type: 'number' }, + clientEmail: { type: 'number' }, + numero: { type: 'number' }, + amountTtcCents: { type: 'number' }, + issueDate: { type: 'number' }, + dueDate: { type: 'number' }, + }, + required: [ + 'clientName', + 'clientEmail', + 'numero', + 'amountTtcCents', + 'issueDate', + 'dueDate', + ], + }, + }, + required: [ + 'clientName', + 'clientEmail', + 'numero', + 'amountTtcCents', + 'issueDate', + 'dueDate', + '_conf', + ], + }, + }, + }, + temperature: 0, + }) + + const content = json?.choices?.[0]?.message?.content + if (typeof content !== 'string') { + throw new Error('Mistral chat: pas de content string dans la réponse') + } + return JSON.parse(content) + } +} diff --git a/bruno/06-Imports/01 Upload (mock).bru b/bruno/06-Imports/01 Upload (mock).bru index 7f675ce..5148fdb 100644 --- a/bruno/06-Imports/01 Upload (mock).bru +++ b/bruno/06-Imports/01 Upload (mock).bru @@ -51,13 +51,12 @@ tests { docs { POST /api/v1/invoices/upload - V1 mock : on envoie un body JSON `{ filenames: [...] }` (pas de fichier - réel). Le service crée un ImportBatch + 1 ImportDraft par filename, en - appelant le `MockOcrProvider` qui invente des champs plausibles depuis - le nom du fichier. + Mode JSON (V1 démo) : body `{ filenames: [...] }` — aucun PDF stocké. + Crée un ImportBatch + 1 ImportDraft par filename via le MockOcrProvider + qui invente des champs plausibles depuis le nom. - Quand Mistral sera branché : on basculera sur multipart `files[]` avec - upload effectif vers MinIO. Le contrat de réponse reste identique. + Pour le vrai OCR avec PDFs : utiliser **02 Upload (multipart)** avec + OCR_PROVIDER=mistral dans le .env. Capture `batchId` et `draftId` (le 1er pending) pour les requêtes suivantes. diff --git a/bruno/06-Imports/02 Upload (multipart Mistral).bru b/bruno/06-Imports/02 Upload (multipart Mistral).bru new file mode 100644 index 0000000..29112b9 --- /dev/null +++ b/bruno/06-Imports/02 Upload (multipart Mistral).bru @@ -0,0 +1,82 @@ +meta { + name: 02 Upload (multipart Mistral) + type: http + seq: 2 +} + +post { + url: {{baseUrl}}/api/v1/invoices/upload + body: multipartForm + auth: inherit +} + +body:multipart-form { + files: @file() + files: @file() +} + +script:post-response { + if (res.getStatus() === 201) { + const batch = res.getBody().data; + bru.setEnvVar("batchId", batch.id); + if (batch.drafts && batch.drafts.length > 0) { + const firstPending = batch.drafts.find(d => d.status === "pending") || batch.drafts[0]; + bru.setEnvVar("draftId", firstPending.id); + } + } +} + +tests { + test("201 Created", function () { + expect(res.getStatus()).to.equal(201); + }); + test("drafts ont un pdfStorageKey non null", function () { + const drafts = res.getBody().data.drafts; + if (drafts.length > 0) { + expect(drafts[0].pdfStorageKey).to.not.be.null; + } + }); +} + +docs { + POST /api/v1/invoices/upload (multipart/form-data) + + Vrai upload OCR : un ou plusieurs fichiers PDF (ou PNG/JPG) sont + uploadés sur MinIO, puis l'OCR provider configuré (`OCR_PROVIDER` du + .env) extrait les champs. + + ## Setup côté Bruno + + Dans le bloc `body:multipart-form` ci-dessus, clique sur le `@file()` + pour sélectionner un PDF depuis ton disque. Tu peux ajouter d'autres + champs `files: @file()` pour uploader plusieurs PDFs en une fois. + + ## Setup côté API + + Dans `apps/api/.env` : + ``` + OCR_PROVIDER=mistral + MISTRAL_API_KEY=ms_xxx... + ``` + + Si `OCR_PROVIDER=mock`, l'upload multipart fonctionne aussi mais le + PDF n'est pas analysé — le MockOcrProvider invente des champs depuis + le nom du fichier (le PDF est juste stocké pour la suite). + + ## Stockage MinIO + + Les fichiers atterrissent dans `import-drafts//.`. + Visibles via la console MinIO http://localhost:9101 (login `rubis` / + `rubis-dev-secret`). + + ## Validation + + - Extensions : pdf, png, jpg, jpeg + - Taille max : 10 MB par fichier + + ## Limites Mistral + + Le provider fait 2 appels (OCR → extraction structurée). Latence + ~3-8s par PDF. Si l'extraction échoue, l'erreur remonte en 500 — pas + de retry V1 (à mettre dans BullMQ pour la prod). +} diff --git a/bruno/06-Imports/02 Get batch.bru b/bruno/06-Imports/03 Get batch.bru similarity index 95% rename from bruno/06-Imports/02 Get batch.bru rename to bruno/06-Imports/03 Get batch.bru index c20cca1..995d3ef 100644 --- a/bruno/06-Imports/02 Get batch.bru +++ b/bruno/06-Imports/03 Get batch.bru @@ -1,7 +1,7 @@ meta { - name: 02 Get batch + name: 03 Get batch type: http - seq: 2 + seq: 3 } get { diff --git a/bruno/06-Imports/03 Validate draft.bru b/bruno/06-Imports/04 Validate draft.bru similarity index 97% rename from bruno/06-Imports/03 Validate draft.bru rename to bruno/06-Imports/04 Validate draft.bru index 94870c3..364d05d 100644 --- a/bruno/06-Imports/03 Validate draft.bru +++ b/bruno/06-Imports/04 Validate draft.bru @@ -1,7 +1,7 @@ meta { - name: 03 Validate draft + name: 04 Validate draft type: http - seq: 3 + seq: 4 } post { diff --git a/bruno/06-Imports/04 Skip draft.bru b/bruno/06-Imports/05 Skip draft.bru similarity index 96% rename from bruno/06-Imports/04 Skip draft.bru rename to bruno/06-Imports/05 Skip draft.bru index 634430b..67ef421 100644 --- a/bruno/06-Imports/04 Skip draft.bru +++ b/bruno/06-Imports/05 Skip draft.bru @@ -1,7 +1,7 @@ meta { - name: 04 Skip draft + name: 05 Skip draft type: http - seq: 4 + seq: 5 } post { diff --git a/bruno/06-Imports/05 Cancel batch.bru b/bruno/06-Imports/06 Cancel batch.bru similarity index 94% rename from bruno/06-Imports/05 Cancel batch.bru rename to bruno/06-Imports/06 Cancel batch.bru index 50d7fc5..69b3592 100644 --- a/bruno/06-Imports/05 Cancel batch.bru +++ b/bruno/06-Imports/06 Cancel batch.bru @@ -1,7 +1,7 @@ meta { - name: 05 Cancel batch + name: 06 Cancel batch type: http - seq: 5 + seq: 6 } delete {