fix(invoice-inbox): read whole PDFs (last-page slice + truncation retry) (#2014)

* fix(invoice-inbox): read whole PDFs (last-page slice + truncation retry)

PDF extraction read only part of well-structured PDFs, two confirmed
mechanisms (21-day prod window: 49 sliced docs, 29 silent empties):

- The auto-extract page budget was 3 (Bedrock-latency legacy, issue #553)
  and the slice kept only the first pages, so multi-page invoices lost the
  final page where totals, OCR and 'Att betala' sit. The budget is now 8 on
  pdf-native backends (Claude reads PDFs directly); the slice always keeps
  the last page. Rasterizing self-host backends keep the old budget of 3.
- A max_tokens-truncated model answer was parsed as-is, failed, and became
  an all-null extraction with no trace. extractFromDocument now reports
  stop_reason max_tokens / finish_reason length as truncated; the extractor
  retries once at double AI_EXTRACTION_MAX_TOKENS and logs
  ai_extraction_truncated either way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012xosyW53HUa9JoFiDayhSk

* fix(invoice-inbox): sweep cutoff covers the slower two-call extraction

Skeptic finding on #2014: the crash-recovery sweep flipped 'processing'
rows to an empty skeleton after 2 minutes, but a deferred extraction can
now legitimately run 3-5 minutes (8 native pages plus one truncation
retry at a doubled token cap), so the sweep stole the row and the CAS
discarded the worker's real result. Cutoff raised to 10 minutes.

Also: pages_partial_note made period-agnostic (old rows were extracted
from first-pages-only slices, so naming the last page was retroactively
wrong for them), and two stale first-pages-only comments updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012xosyW53HUa9JoFiDayhSk

* fix(invoice-inbox): keep the first extraction response when the retry throws

CodeRabbit finding on #2014: a throttled/failed retry call bubbled to the
outer catch before rawText was assigned, discarding a first response whose
text may parse fine despite the truncation flag. The retry is now caught
locally (logged as ai_extraction_retry_failed) and the first result flows on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012xosyW53HUa9JoFiDayhSk

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-28 18:57:02 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent e8aa0670ca
commit a1cafe495f
15 changed files with 347 additions and 59 deletions
@@ -273,6 +273,80 @@ describe('extractInvoiceFields', () => {
expect(extractJsonObject(oversized)).toBe(oversized)
})
// ── max_tokens truncation retry (2026-08) ──────────────────
// Line-item-heavy documents can blow the output cap; the truncated JSON
// used to parse to nothing and look like an unreadable document.
it('retries once with a doubled cap when the output was truncated at max_tokens', async () => {
mockCreate
.mockResolvedValueOnce({
content: [{ type: 'text', text: JSON.stringify(VALID_RESULT).slice(0, 40) }],
stop_reason: 'max_tokens',
})
.mockReturnValueOnce(aiResponse(VALID_RESULT))
const { data } = await extractInvoiceFields({
buffer: Buffer.from('%PDF'),
mimeType: 'application/pdf',
fileName: 'many-line-items.pdf',
})
expect(mockCreate).toHaveBeenCalledTimes(2)
const firstMax = mockCreate.mock.calls[0][0].max_tokens
expect(mockCreate.mock.calls[1][0].max_tokens).toBe(firstMax * 2)
expect(data.totals.total).toBe(6.25)
expect(data.confidence).toBe(1)
})
it('falls back to the empty result when the retry is truncated too', async () => {
mockCreate
.mockResolvedValueOnce({
content: [{ type: 'text', text: '{"lineItems":[{"desc' }],
stop_reason: 'max_tokens',
})
.mockResolvedValueOnce({
content: [{ type: 'text', text: '{"lineItems":[{"description":"still cut' }],
stop_reason: 'max_tokens',
})
const { data, rawText } = await extractInvoiceFields({
buffer: Buffer.from('%PDF'),
mimeType: 'application/pdf',
fileName: 'many-line-items.pdf',
})
expect(mockCreate).toHaveBeenCalledTimes(2)
expect(rawText).toBe('{"lineItems":[{"description":"still cut')
expect(data.totals.total).toBeNull()
expect(data.confidence).toBe(0)
})
it('keeps the first response when the retry call itself throws', async () => {
// The truncated first answer happens to be complete valid JSON (the flag
// can fire on the last token); a throttled retry must not discard it.
mockCreate
.mockResolvedValueOnce({
content: [{ type: 'text', text: JSON.stringify(VALID_RESULT) }],
stop_reason: 'max_tokens',
})
.mockRejectedValueOnce(new Error('throttled'))
const { data, rawText } = await extractInvoiceFields({
buffer: Buffer.from('%PDF'),
mimeType: 'application/pdf',
fileName: 'many-line-items.pdf',
})
expect(mockCreate).toHaveBeenCalledTimes(2)
expect(rawText).toBe(JSON.stringify(VALID_RESULT))
expect(data.totals.total).toBe(6.25)
expect(data.confidence).toBe(1)
})
it('does not retry when the answer completed under the cap', async () => {
mockCreate.mockReturnValueOnce(aiResponse(VALID_RESULT))
await extractInvoiceFields({
buffer: Buffer.from('%PDF'),
mimeType: 'application/pdf',
fileName: 'kvitto.pdf',
})
expect(mockCreate).toHaveBeenCalledTimes(1)
})
it('returns empty result when AI response fails schema validation', async () => {
mockCreate.mockReturnValueOnce(
aiResponse({ supplier: { name: 'X' } /* missing required keys */ })
@@ -0,0 +1,98 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { PDFDocument } from 'pdf-lib'
import {
slicePdfForExtraction,
maxPagesForAutoExtract,
MAX_PAGES_FOR_AUTO_EXTRACT,
MAX_PAGES_FOR_AUTO_EXTRACT_NATIVE,
} from '@/extensions/general/invoice-inbox/lib/upload-and-extract'
// Pages get index-encoded widths (500 + i) so tests can assert exactly WHICH
// source pages survived the slice, not just how many.
async function makePdf(pageCount: number): Promise<ArrayBuffer> {
const pdf = await PDFDocument.create()
for (let i = 0; i < pageCount; i++) pdf.addPage([500 + i, 700])
const bytes = await pdf.save()
const out = new ArrayBuffer(bytes.byteLength)
new Uint8Array(out).set(bytes)
return out
}
async function pageWidths(buffer: ArrayBuffer): Promise<number[]> {
const pdf = await PDFDocument.load(buffer)
return Array.from({ length: pdf.getPageCount() }, (_, i) =>
Math.round(pdf.getPage(i).getSize().width)
)
}
describe('slicePdfForExtraction', () => {
it('keeps the first maxPages-1 pages plus the LAST page (where totals sit)', async () => {
const sliced = await slicePdfForExtraction(await makePdf(10), 8)
expect(sliced).not.toBeNull()
// Pages 0..6 plus page 9: widths 500..506 and 509.
expect(await pageWidths(sliced!)).toEqual([500, 501, 502, 503, 504, 505, 506, 509])
})
it('keeps the last page also on the old 3-page budget', async () => {
const sliced = await slicePdfForExtraction(await makePdf(5), 3)
expect(await pageWidths(sliced!)).toEqual([500, 501, 504])
})
it('copies the document unchanged when it fits the budget', async () => {
const sliced = await slicePdfForExtraction(await makePdf(3), 8)
expect(await pageWidths(sliced!)).toEqual([500, 501, 502])
})
it('returns null on an unparseable buffer', async () => {
const garbage = new TextEncoder().encode('not a pdf').buffer as ArrayBuffer
expect(await slicePdfForExtraction(garbage, 8)).toBeNull()
})
})
describe('maxPagesForAutoExtract', () => {
const ENV = [
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'ANTHROPIC_API_KEY',
'AI_PROVIDER',
'AI_BASE_URL',
'AI_API_KEY',
'AI_MODEL',
'AI_PDF_MODE',
] as const
let saved: Record<string, string | undefined> = {}
beforeEach(() => {
saved = {}
for (const k of ENV) {
saved[k] = process.env[k]
delete process.env[k]
}
})
afterEach(() => {
for (const k of ENV) {
if (saved[k] === undefined) delete process.env[k]
else process.env[k] = saved[k]
}
})
it('uses the higher budget when the backend reads PDFs natively (Claude)', () => {
process.env.AWS_ACCESS_KEY_ID = 'AKIAEXAMPLE'
process.env.AWS_SECRET_ACCESS_KEY = 'secret'
expect(maxPagesForAutoExtract()).toBe(MAX_PAGES_FOR_AUTO_EXTRACT_NATIVE)
})
it('keeps the conservative budget on a rasterizing OpenAI-compatible backend', () => {
process.env.AI_PROVIDER = 'openai-compatible'
process.env.AI_BASE_URL = 'http://localhost:8000/v1'
process.env.AI_MODEL = 'some-model'
expect(maxPagesForAutoExtract()).toBe(MAX_PAGES_FOR_AUTO_EXTRACT)
})
it('follows AI_PDF_MODE=native on an OpenAI-compatible backend', () => {
process.env.AI_PROVIDER = 'openai-compatible'
process.env.AI_BASE_URL = 'http://localhost:8000/v1'
process.env.AI_MODEL = 'some-model'
process.env.AI_PDF_MODE = 'native'
expect(maxPagesForAutoExtract()).toBe(MAX_PAGES_FOR_AUTO_EXTRACT_NATIVE)
})
})
@@ -137,9 +137,12 @@ function buildCtx(supabase: unknown): ExtensionContext {
} as ExtensionContext
}
// The last page gets a distinct size so tests can assert the slice kept it:
// slicePdfForExtraction takes the first pages PLUS the last (totals page).
async function makePdfBuffer(pageCount: number): Promise<Uint8Array> {
const pdf = await PDFDocument.create()
for (let i = 0; i < pageCount; i++) pdf.addPage([612, 792])
for (let i = 0; i < pageCount - 1; i++) pdf.addPage([612, 792])
pdf.addPage([400, 600])
return pdf.save()
}
@@ -165,7 +168,7 @@ beforeEach(() => {
})
describe('POST /upload: staged extraction + page-count gate (issue #553)', () => {
it('defers long PDFs: responds processing, then slices to 3 pages and flips to received', async () => {
it('defers long PDFs: responds processing, then slices to 8 pages (incl. the last) and flips to received', async () => {
const captured: { row?: Record<string, unknown> } = {}
const flip: FlipCapture = { filters: [] }
const supabase = makeSupabase(captured, flip)
@@ -175,7 +178,7 @@ describe('POST /upload: staged extraction + page-count gate (issue #553)', () =>
rawText: 'ok',
})
const req = await makeUploadRequest(6)
const req = await makeUploadRequest(10)
const res = await uploadRoute.handler(req, buildCtx(supabase))
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(res)
@@ -186,16 +189,20 @@ describe('POST /upload: staged extraction + page-count gate (issue #553)', () =>
expect(body.data.extracted_data).toBeNull()
expect(body.data.extraction_skipped).toBe(false)
expect(body.data.skip_reason).toBeNull()
expect(body.data.page_count).toBe(6)
expect(body.data.page_count).toBe(10)
expect(captured.row?.status).toBe('processing')
expect(captured.row?.extracted_data).toBeNull()
expect(captured.row?.extraction_skipped).toBe(false)
// The deferred worker extracts from the sliced copy, not the original.
// The deferred worker extracts from the sliced copy, not the original:
// the first 7 pages plus the LAST page (the distinct 400x600 one), where
// invoice totals usually sit.
await vi.waitFor(() => expect(extractInvoiceFields).toHaveBeenCalledOnce())
const sentBuffer = vi.mocked(extractInvoiceFields).mock.calls[0][0].buffer
const sentPdf = await PDFDocument.load(sentBuffer)
expect(sentPdf.getPageCount()).toBe(3)
expect(sentPdf.getPageCount()).toBe(8)
const lastPage = sentPdf.getPage(7).getSize()
expect(lastPage).toEqual({ width: 400, height: 600 })
// ...and CAS-flips the processing row to received, with the truncation
// recorded in extracted_data.pages rather than as a skip.
@@ -203,8 +210,8 @@ describe('POST /upload: staged extraction + page-count gate (issue #553)', () =>
expect(flip.payload?.status).toBe('received')
expect(flip.payload?.extraction_skipped).toBe(false)
expect((flip.payload?.extracted_data as { pages?: unknown })?.pages).toEqual({
total: 6,
analyzed: 3,
total: 10,
analyzed: 8,
})
expect(flip.filters).toEqual([
['id', 'inbox-1'],
@@ -212,7 +219,7 @@ describe('POST /upload: staged extraction + page-count gate (issue #553)', () =>
])
})
it('defers PDFs at or below the page-count limit and extracts the full buffer', async () => {
it('defers PDFs at or below the native page budget (6 pages) and extracts the full buffer', async () => {
const captured: { row?: Record<string, unknown> } = {}
const flip: FlipCapture = { filters: [] }
const supabase = makeSupabase(captured, flip)
@@ -222,7 +229,9 @@ describe('POST /upload: staged extraction + page-count gate (issue #553)', () =>
rawText: 'ok',
})
const req = await makeUploadRequest(2)
// 6 pages gated on the old budget of 3; on the native budget of 8 the
// whole document is read (this is the regression the raise fixes).
const req = await makeUploadRequest(6)
const res = await uploadRoute.handler(req, buildCtx(supabase))
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(res)
@@ -230,12 +239,12 @@ describe('POST /upload: staged extraction + page-count gate (issue #553)', () =>
expect(body.data.status).toBe('processing')
expect(body.data.extraction_skipped).toBe(false)
expect(body.data.skip_reason).toBeNull()
expect(body.data.page_count).toBe(2)
expect(body.data.page_count).toBe(6)
await vi.waitFor(() => expect(extractInvoiceFields).toHaveBeenCalledOnce())
const sentBuffer = vi.mocked(extractInvoiceFields).mock.calls[0][0].buffer
const sentPdf = await PDFDocument.load(sentBuffer)
expect(sentPdf.getPageCount()).toBe(2)
expect(sentPdf.getPageCount()).toBe(6)
await vi.waitFor(() => expect(flip.payload).toBeDefined())
expect(flip.payload?.status).toBe('received')
+8 -7
View File
@@ -15,7 +15,7 @@ import {
countPdfPages,
slicePdfForExtraction,
MAX_FILE_SIZE,
MAX_PAGES_FOR_AUTO_EXTRACT,
maxPagesForAutoExtract,
UPLOAD_ALLOWED_MIME_TYPES,
EMAIL_ALLOWED_MIME_TYPES,
ensureHtmlDocument,
@@ -758,13 +758,14 @@ export const invoiceInboxExtension: Extension = {
})
// Same page handling as /upload (issue #553): long PDFs extract
// from a slice of their first pages; the skip only remains for
// unsliceable (encrypted/malformed) PDFs. Sandbox companies skip
// Bedrock unconditionally.
// from a slice (first pages + the last page); the skip only remains
// for unsliceable (encrypted/malformed) PDFs. Sandbox companies
// skip Bedrock unconditionally.
const maxAutoExtractPages = maxPagesForAutoExtract()
const pageCount =
file.type === 'application/pdf' ? await countPdfPages(buffer) : null
const gatedByPageCount =
pageCount != null && pageCount > MAX_PAGES_FOR_AUTO_EXTRACT
pageCount != null && pageCount > maxAutoExtractPages
const sandbox = await isSandboxCompany(ctx.supabase, ctx.companyId)
// Paid-tier gate: no `ai` capability → no Bedrock OCR (seed empty
// skeleton; the attached document is still stored). Same paywall as
@@ -772,7 +773,7 @@ export const invoiceInboxExtension: Extension = {
const hasAiEntitlement = await hasCapability(ctx.supabase, ctx.companyId, CAPABILITY.ai)
const slicedBuffer =
gatedByPageCount && hasAiEntitlement && !sandbox
? await slicePdfForExtraction(buffer, MAX_PAGES_FOR_AUTO_EXTRACT)
? await slicePdfForExtraction(buffer, maxAutoExtractPages)
: null
const skipReason: 'no_ai_entitlement' | 'too_many_pages' | 'sandbox' | null =
!hasAiEntitlement
@@ -793,7 +794,7 @@ export const invoiceInboxExtension: Extension = {
})
const { data: extracted } = extraction
if (!skipExtraction && slicedBuffer != null && pageCount != null) {
extracted.pages = { total: pageCount, analyzed: MAX_PAGES_FOR_AUTO_EXTRACT }
extracted.pages = { total: pageCount, analyzed: maxAutoExtractPages }
}
await mirrorExtractionToDocument(doc.id, {
data: extracted,
@@ -524,13 +524,14 @@ export async function extractInvoiceFields(
let rawText: string | null = null
let model: string | null = null
try {
const result = await service.extractFromDocument({
const baseMaxTokens = readAiConfig().extractionMaxTokens
const request = {
document: toDocumentInput(input),
system: SYSTEM_PROMPT,
instruction: EXTRACTION_INSTRUCTION,
maxTokens: readAiConfig().extractionMaxTokens,
jsonSchema: EXTRACTION_JSON_SCHEMA,
})
}
let result = await service.extractFromDocument({ ...request, maxTokens: baseMaxTokens })
if (!result.ok) {
// Not a failure: the deployment cannot read this document at all.
// `ai_unconfigured` is the self-host "no key yet" case the 30 s
@@ -538,6 +539,43 @@ export async function extractInvoiceFields(
log.warn('AI extraction skipped', { file_name_hash: fileNameHash, reason: result.skipped })
return { data: emptyResult(), rawText: null, skipped: result.skipped }
}
if (result.truncated) {
// The output hit maxTokens mid-JSON (line-item-heavy documents). Left
// alone this parsed to nothing and looked like an unreadable document;
// one retry at double the cap recovers it. A second truncation falls
// through to the normal parse path, which fails visibly in the log
// below instead of silently.
log.warn('ai_extraction_truncated', {
file_name_hash: fileNameHash,
max_tokens: baseMaxTokens,
retrying: true,
})
// A retry that THROWS (throttle, network) must not sink the first
// response: its text may still parse despite the truncation flag, and
// the outer catch would otherwise return the empty skeleton with
// rawText null. Swallow locally and continue with the first result.
try {
const retry = await service.extractFromDocument({
...request,
maxTokens: baseMaxTokens * 2,
})
if (retry.ok) {
result = retry
if (retry.truncated) {
log.warn('ai_extraction_truncated', {
file_name_hash: fileNameHash,
max_tokens: baseMaxTokens * 2,
retrying: false,
})
}
}
} catch (retryErr) {
log.warn('ai_extraction_retry_failed', {
file_name_hash: fileNameHash,
error: retryErr instanceof Error ? retryErr.message : String(retryErr),
})
}
}
rawText = result.text
model = result.model
@@ -21,11 +21,16 @@ import { emptyResult } from './extract-invoice-fields'
const log = createLogger('invoice-inbox/sweep')
/**
* A deferred extraction is one Bedrock call (the WhatsApp cron budgets
* 10-60s for the same call). Two minutes of silence means no live worker
* can still deliver a flip that beats the sweep by enough to matter.
* A deferred extraction is now up to TWO Bedrock calls on up to 8 native PDF
* pages: the base call can spend its full output budget (~90-150s at 8192
* tokens) before the truncation retry doubles the cap and runs again. A live
* worker can therefore be legitimately silent for several minutes; a cutoff
* shorter than its worst case makes the sweep steal the row and discard the
* worker's real result via the status CAS. Ten minutes clears the two-call
* worst case with margin while still flipping genuinely crashed rows well
* before anyone files a support mail about a stuck item.
*/
export const PROCESSING_STUCK_MS = 2 * 60 * 1000
export const PROCESSING_STUCK_MS = 10 * 60 * 1000
const BATCH = 50
export interface InboxSweepSummary {
@@ -42,12 +42,24 @@ export function sanitiseMime(raw: string | null | undefined): string {
export const MAX_FILE_SIZE = 10 * 1024 * 1024
// AI extraction is tuned for single-page receipts/invoices. Documents above
// this page count tend to be sales reports, bank statements, or contracts:
// Bedrock churns for minutes and still extracts nothing useful (issue #553).
// Above the limit we skip extraction entirely; the document still lands in
// the inbox and can be attached to a transaction or converted manually.
// Page budget for auto-extraction. Documents far above it tend to be sales
// reports, bank statements, or contracts (issue #553); those are sliced (see
// slicePdfForExtraction) rather than read whole. The budget depends on how
// the backend reads PDFs:
// - native (Claude on hosted): the model reads PDF bytes directly and
// handles long documents fine; 8 pages covers real multi-page invoices
// whose totals sit on a later page without opening the door to 100-page
// statements.
// - rasterize (OpenAI-compatible self-host): every page becomes a PNG in
// the prompt, so the old conservative budget of 3 stays.
export const MAX_PAGES_FOR_AUTO_EXTRACT = 3
export const MAX_PAGES_FOR_AUTO_EXTRACT_NATIVE = 8
export function maxPagesForAutoExtract(): number {
return getAiStatus().capabilities.pdfNative
? MAX_PAGES_FOR_AUTO_EXTRACT_NATIVE
: MAX_PAGES_FOR_AUTO_EXTRACT
}
// Returns the page count for a PDF buffer, or null if the buffer isn't a
// parseable PDF. Errors fall through so callers can treat "unknown" the same
@@ -61,11 +73,13 @@ export async function countPdfPages(buffer: ArrayBuffer): Promise<number | null>
}
}
// Long PDFs used to skip extraction entirely (issue #553). Invoice data
// almost always sits on the first page(s), so instead we extract from a
// slim copy of the first MAX_PAGES_FOR_AUTO_EXTRACT pages and record the
// truncation in extracted_data.pages. Returns null when slicing fails
// (encrypted/malformed PDF) so the caller can fall back to the old skip.
// Long PDFs used to skip extraction entirely (issue #553). Instead we extract
// from a slim copy and record the truncation in extracted_data.pages. The
// slice is the first maxPages-1 pages PLUS the last page: on multi-page
// invoices the totals, OCR number and "Att betala" routinely sit on the final
// page, and a first-pages-only slice read everything except the amounts.
// Returns null when slicing fails (encrypted/malformed PDF) so the caller can
// fall back to the old skip.
export async function slicePdfForExtraction(
buffer: ArrayBuffer,
maxPages: number
@@ -73,10 +87,12 @@ export async function slicePdfForExtraction(
try {
const src = await PDFDocument.load(buffer, { updateMetadata: false })
const dst = await PDFDocument.create()
const pages = await dst.copyPages(
src,
Array.from({ length: Math.min(maxPages, src.getPageCount()) }, (_, i) => i)
)
const total = src.getPageCount()
const indices =
total > maxPages && maxPages >= 2
? [...Array.from({ length: maxPages - 1 }, (_, i) => i), total - 1]
: Array.from({ length: Math.min(maxPages, total) }, (_, i) => i)
const pages = await dst.copyPages(src, indices)
for (const page of pages) dst.addPage(page)
const bytes = await dst.save()
// Copy into a fresh ArrayBuffer: Uint8Array.buffer is ArrayBufferLike
@@ -335,16 +351,18 @@ export async function uploadAndExtract(
console.error('[invoice-inbox] Failed to append DocumentIngested:', err)
}
// Page-count gate (issue #553): PDFs above MAX_PAGES_FOR_AUTO_EXTRACT
// skip extraction. Bedrock would otherwise block the upload response for
// minutes on a 6-page sales report and return nothing useful. Images and
// non-PDFs are never gated (single-page by definition). countPdfPages
// returns null on malformed PDFs: we treat null as "not gated" and fall
// through to the existing extraction path so today's behavior is preserved.
// Page-count gate (issue #553): PDFs above the auto-extract page budget
// are sliced before extraction. Bedrock would otherwise block the upload
// response for minutes on a long sales report and return nothing useful.
// Images and non-PDFs are never gated (single-page by definition).
// countPdfPages returns null on malformed PDFs: we treat null as "not
// gated" and fall through to the existing extraction path so today's
// behavior is preserved.
const maxAutoExtractPages = maxPagesForAutoExtract()
const pageCount =
file.type === 'application/pdf' ? await countPdfPages(file.buffer) : null
const gatedByPageCount =
pageCount != null && pageCount > MAX_PAGES_FOR_AUTO_EXTRACT
pageCount != null && pageCount > maxAutoExtractPages
const sandbox = await isSandboxCompany(supabase, companyId)
// Paid-tier gate: AI document OCR (Bedrock, via extractInvoiceFields) is the
// `ai` capability. A company without it (free/manual tier) must never trigger
@@ -419,6 +437,7 @@ export async function uploadAndExtract(
file,
pageCount,
gatedByPageCount,
maxAutoExtractPages,
})
return {
@@ -434,12 +453,12 @@ export async function uploadAndExtract(
}
}
// Long PDFs are sliced to their first pages instead of skipped, but only
// when extraction would actually run: slicing after an entitlement/sandbox/
// opt-out verdict would be wasted CPU.
// Long PDFs are sliced (first pages + the last page) instead of skipped,
// but only when extraction would actually run: slicing after an
// entitlement/sandbox/opt-out verdict would be wasted CPU.
const slicedBuffer =
gatedByPageCount && syncSkipReason === null
? await slicePdfForExtraction(file.buffer, MAX_PAGES_FOR_AUTO_EXTRACT)
? await slicePdfForExtraction(file.buffer, maxAutoExtractPages)
: null
// Skip-reason priority: no-AI-entitlement > sandbox > client opt-out >
// page-count. Opt-out outranks the page gate (an opted-out caller never
@@ -464,7 +483,7 @@ export async function uploadAndExtract(
})
const { data: extracted, rawText } = extraction
if (!skipExtraction && slicedBuffer != null && pageCount != null) {
extracted.pages = { total: pageCount, analyzed: MAX_PAGES_FOR_AUTO_EXTRACT }
extracted.pages = { total: pageCount, analyzed: maxAutoExtractPages }
}
// Supplier match by org-nr, then VAT number, then case-insensitive name
@@ -563,6 +582,9 @@ interface DeferredExtractionJob {
file: { name: string; buffer: ArrayBuffer; type: string }
pageCount: number | null
gatedByPageCount: boolean
/** Snapshot of maxPagesForAutoExtract() from the request, so the gate
* verdict and the slice/stamp below cannot disagree. */
maxAutoExtractPages: number
}
/**
@@ -588,7 +610,7 @@ function scheduleDeferredExtraction(job: DeferredExtractionJob): void {
let skipReason: string | null = null
try {
const slicedBuffer = job.gatedByPageCount
? await slicePdfForExtraction(job.file.buffer, MAX_PAGES_FOR_AUTO_EXTRACT)
? await slicePdfForExtraction(job.file.buffer, job.maxAutoExtractPages)
: null
if (job.gatedByPageCount && slicedBuffer == null) {
extractionSkipped = true
@@ -610,7 +632,7 @@ function scheduleDeferredExtraction(job: DeferredExtractionJob): void {
skipReason = result.skipped
}
if (slicedBuffer != null && job.pageCount != null) {
extracted.pages = { total: job.pageCount, analyzed: MAX_PAGES_FOR_AUTO_EXTRACT }
extracted.pages = { total: job.pageCount, analyzed: job.maxAutoExtractPages }
}
}
} catch (err) {