import { createHash } from 'node:crypto'; export interface PageInput { page: number; embeddedText?: string; imagePath?: string; } export interface TextChunk { id: string; documentId: string; extractedTextHash: string; page: number; part: number; extraction: 'embedded' | 'ocr'; text: string; } export interface IngestReport { documentId: string; extractedTextHash: string; processedPages: number; chunks: TextChunk[]; warnings: string[]; } export type ImageRecognizer = (imagePath: string) => Promise; export const checksum = (text: string): string => createHash('sha256').update(text).digest('hex'); export function splitIntoChunks(text: string, maxChars = 280): string[] { if (!Number.isInteger(maxChars) || maxChars < 40) { throw new Error('maxChars must be an integer of at least 40'); } const normalized = text.replace(/\s+/g, ' ').trim(); if (!normalized) return []; const chunks: string[] = []; const words = normalized.split(' '); let current = ''; for (const word of words) { if (word.length > maxChars) { if (current) { chunks.push(current); current = ''; } for (let start = 0; start < word.length; start += maxChars) { chunks.push(word.slice(start, start + maxChars)); } continue; } if (!current) { current = word; continue; } if (current.length + 1 + word.length <= maxChars) { current += ` ${word}`; } else { chunks.push(current); current = word; } } if (current) chunks.push(current); return chunks; } export async function ingestPages( documentId: string, pages: readonly PageInput[], recognize: ImageRecognizer, maxChars = 280, ): Promise { if (!/^[a-z0-9-]+$/i.test(documentId)) throw new Error('Invalid document ID'); if (!Number.isInteger(maxChars) || maxChars < 40) { throw new Error('maxChars must be an integer of at least 40'); } const uniquePages = new Set(); const warnings: string[] = []; const extracted: Array<{ page: number; text: string; extraction: 'embedded' | 'ocr' }> = []; const orderedPages = [...pages].sort((a, b) => a.page - b.page); // Validate the whole request before invoking an external recognizer. for (const input of orderedPages) { if (!Number.isInteger(input.page) || input.page < 1 || uniquePages.has(input.page)) { throw new Error('Page numbers must be unique positive integers'); } uniquePages.add(input.page); } for (const input of orderedPages) { const embedded = input.embeddedText?.trim() ?? ''; if (embedded.length >= 35 || !input.imagePath) { if (embedded) extracted.push({ page: input.page, text: embedded, extraction: 'embedded' }); else warnings.push(`Page ${input.page}: no usable text or image`); continue; } try { const ocr = (await recognize(input.imagePath)).trim(); if (ocr) extracted.push({ page: input.page, text: ocr, extraction: 'ocr' }); else if (embedded) { extracted.push({ page: input.page, text: embedded, extraction: 'embedded' }); warnings.push(`Page ${input.page}: empty OCR; preserved available text`); } else warnings.push(`Page ${input.page}: OCR returned no text`); } catch { if (embedded) extracted.push({ page: input.page, text: embedded, extraction: 'embedded' }); warnings.push(`Page ${input.page}: OCR unavailable; continued with partial result`); } } const extractedTextHash = checksum(JSON.stringify(extracted.map(({ page, text }) => [page, text]))); const chunks = extracted.flatMap(({ page, text, extraction }) => splitIntoChunks(text, maxChars).map((value, index) => ({ id: `${documentId}-p${String(page).padStart(3, '0')}-c${String(index + 1).padStart(2, '0')}`, documentId, extractedTextHash, page, part: index + 1, extraction, text: value, })), ); return { documentId, extractedTextHash, processedPages: uniquePages.size, chunks, warnings }; } export function toJsonl(chunks: readonly TextChunk[]): string { return chunks.map(chunk => JSON.stringify(chunk)).join('\n') + (chunks.length ? '\n' : ''); }