feat(mistral-OCR): added mistral tool and block for parsing pdfs (#217)

This commit is contained in:
Emir Karabeg
2025-04-02 01:05:51 -07:00
committed by GitHub
parent dcd3b30c1d
commit 852945b925
6 changed files with 736 additions and 4 deletions
+199
View File
@@ -0,0 +1,199 @@
import { MistralParserOutput } from '@/tools/mistral/parser'
import { BlockConfig } from '../types'
import { MistralIcon } from '@/components/icons'
export const MistralParseBlock: BlockConfig<MistralParserOutput> = {
type: 'mistral_parse',
name: 'Mistral PDF Parser',
description: 'Extract text from PDF documents',
longDescription:
'Extract text and structure from PDF documents using Mistral\'s OCR API. Enter a URL to a PDF document, configure processing options, and get the content in your preferred format.',
category: 'tools',
bgColor: '#000000',
icon: MistralIcon,
subBlocks: [
{
id: 'filePath',
title: 'PDF Document URL',
type: 'short-input',
layout: 'full',
placeholder: 'Enter full URL to a PDF document (https://example.com/document.pdf)',
},
{
id: 'resultType',
title: 'Output Format',
type: 'dropdown',
layout: 'full',
options: [
{ id: 'markdown', label: 'Markdown (Formatted)' },
{ id: 'text', label: 'Plain Text' },
{ id: 'json', label: 'JSON (Raw)' }
],
},
{
id: 'pages',
title: 'Specific Pages',
type: 'short-input',
layout: 'full',
placeholder: 'e.g. 0,1,2 (leave empty for all pages)',
},
/*
* Image-related parameters - temporarily disabled
* Uncomment if PDF image extraction is needed
*
{
id: 'includeImageBase64',
title: 'Include PDF Images',
type: 'switch',
layout: 'half',
},
{
id: 'imageLimit',
title: 'Max Images',
type: 'short-input',
layout: 'half',
placeholder: 'Maximum number of images to extract',
},
{
id: 'imageMinSize',
title: 'Min Image Size (px)',
type: 'short-input',
layout: 'half',
placeholder: 'Min width/height in pixels',
},
*/
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
layout: 'full',
placeholder: 'Enter your Mistral API key',
password: true,
},
],
tools: {
access: ['mistral_parser'],
config: {
tool: () => 'mistral_parser',
params: (params) => {
// Basic validation
if (!params || !params.apiKey || params.apiKey.trim() === '') {
throw new Error('Mistral API key is required');
}
if (!params || !params.filePath || params.filePath.trim() === '') {
throw new Error('PDF Document URL is required');
}
// Validate URL format
let validatedUrl;
try {
// Try to create a URL object to validate format
validatedUrl = new URL(params.filePath.trim());
// Ensure URL is using HTTP or HTTPS protocol
if (!['http:', 'https:'].includes(validatedUrl.protocol)) {
throw new Error(`URL must use HTTP or HTTPS protocol. Found: ${validatedUrl.protocol}`);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Invalid URL format: ${errorMessage}`);
}
// Process pages input (convert from comma-separated string to array of numbers)
let pagesArray: number[] | undefined = undefined;
if (params.pages && params.pages.trim() !== '') {
try {
pagesArray = params.pages
.split(',')
.map((p: string) => p.trim())
.filter((p: string) => p.length > 0)
.map((p: string) => {
const num = parseInt(p, 10);
if (isNaN(num) || num < 0) {
throw new Error(`Invalid page number: ${p}`);
}
return num;
});
if (pagesArray && pagesArray.length === 0) {
pagesArray = undefined;
}
} catch (error: any) {
throw new Error(`Page number format error: ${error.message}`);
}
}
// Process numeric inputs
let imageLimit: number | undefined = undefined;
if (params.imageLimit && params.imageLimit.trim() !== '') {
const limit = parseInt(params.imageLimit, 10);
if (!isNaN(limit) && limit > 0) {
imageLimit = limit;
} else {
throw new Error('Image limit must be a positive number');
}
}
let imageMinSize: number | undefined = undefined;
if (params.imageMinSize && params.imageMinSize.trim() !== '') {
const size = parseInt(params.imageMinSize, 10);
if (!isNaN(size) && size > 0) {
imageMinSize = size;
} else {
throw new Error('Minimum image size must be a positive number');
}
}
// Return structured parameters for the tool
const parameters: any = {
filePath: validatedUrl.toString(),
apiKey: params.apiKey.trim(),
resultType: params.resultType || 'markdown',
};
// Add optional parameters if they're defined
if (pagesArray && pagesArray.length > 0) {
parameters.pages = pagesArray;
}
/*
* Image-related parameters - temporarily disabled
* Uncomment if PDF image extraction is needed
*
if (typeof params.includeImageBase64 === 'boolean') {
parameters.includeImageBase64 = params.includeImageBase64;
}
if (imageLimit !== undefined) {
parameters.imageLimit = imageLimit;
}
if (imageMinSize !== undefined) {
parameters.imageMinSize = imageMinSize;
}
*/
return parameters;
},
},
},
inputs: {
filePath: { type: 'string', required: true },
apiKey: { type: 'string', required: true },
resultType: { type: 'string', required: false },
pages: { type: 'string', required: false },
// Image-related inputs - temporarily disabled
// includeImageBase64: { type: 'boolean', required: false },
// imageLimit: { type: 'string', required: false },
// imageMinSize: { type: 'string', required: false },
},
outputs: {
response: {
type: {
content: 'string',
metadata: 'json',
},
},
},
}
+5 -2
View File
@@ -8,6 +8,7 @@ import { GoogleDocsBlock } from './blocks/docs'
import { GoogleDriveBlock } from './blocks/drive'
import { EvaluatorBlock } from './blocks/evaluator'
import { ExaBlock } from './blocks/exa'
import { MistralParseBlock } from './blocks/mistral-parse'
import { FileBlock } from './blocks/file'
import { FirecrawlBlock } from './blocks/firecrawl'
import { FunctionBlock } from './blocks/function'
@@ -42,11 +43,12 @@ export {
AgentBlock,
AirtableBlock,
ApiBlock,
FileBlock,
MistralParseBlock,
FunctionBlock,
VisionBlock,
FirecrawlBlock,
// GuestyBlock,
FileBlock,
JinaBlock,
TranslateBlock,
SlackBlock,
@@ -86,8 +88,9 @@ const blocks: Record<string, BlockConfig> = {
confluence: ConfluenceBlock,
evaluator: EvaluatorBlock,
exa: ExaBlock,
file: FileBlock,
mistral_parse: MistralParseBlock,
firecrawl: FirecrawlBlock,
file: FileBlock,
function: FunctionBlock,
github: GitHubBlock,
gmail: GmailBlock,
+56
View File
@@ -1819,3 +1819,59 @@ export function DocumentIcon(props: SVGProps<SVGSVGElement>) {
</svg>
)
}
export function MistralIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
width="22"
height="22"
viewBox="1 0.5 24 22"
fill="none"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid meet"
>
<g clipPath="url(#clip0_1621_58)">
<path d="M17.4541 0H21.8177V4.39481H17.4541V0Z" fill="black" />
<path d="M19.6367 0H24.0003V4.39481H19.6367V0Z" fill="#F7D046" />
<path
d="M0 0H4.36359V4.39481H0V0ZM0 4.39481H4.36359V8.78961H0V4.39481ZM0 8.78971H4.36359V13.1845H0V8.78971ZM0 13.1845H4.36359V17.5793H0V13.1845ZM0 17.5794H4.36359V21.9742H0V17.5794Z"
fill="black"
/>
<path d="M2.18164 0H6.54523V4.39481H2.18164V0Z" fill="#F7D046" />
<path
d="M19.6362 4.39478H23.9998V8.78958H19.6362V4.39478ZM2.18164 4.39478H6.54523V8.78958H2.18164V4.39478Z"
fill="#F2A73B"
/>
<path d="M13.0908 4.39478H17.4544V8.78958H13.0908V4.39478Z" fill="black" />
<path
d="M15.2732 4.39478H19.6368V8.78958H15.2732V4.39478ZM6.5459 4.39478H10.9095V8.78958H6.5459V4.39478Z"
fill="#F2A73B"
/>
<path
d="M10.9096 8.78979H15.2732V13.1846H10.9096V8.78979ZM15.2732 8.78979H19.6368V13.1846H15.2732V8.78979ZM6.5459 8.78979H10.9096V13.1846H6.5459V8.78979Z"
fill="#EE792F"
/>
<path d="M8.72754 13.1846H13.0911V17.5794H8.72754V13.1846Z" fill="black" />
<path d="M10.9092 13.1846H15.2728V17.5794H10.9092V13.1846Z" fill="#EB5829" />
<path
d="M19.6362 8.78979H23.9998V13.1846H19.6362V8.78979ZM2.18164 8.78979H6.54523V13.1846H2.18164V8.78979Z"
fill="#EE792F"
/>
<path d="M17.4541 13.1846H21.8177V17.5794H17.4541V13.1846Z" fill="black" />
<path d="M19.6367 13.1846H24.0003V17.5794H19.6367V13.1846Z" fill="#EB5829" />
<path d="M17.4541 17.5793H21.8177V21.9742H17.4541V17.5793Z" fill="black" />
<path d="M2.18164 13.1846H6.54523V17.5794H2.18164V13.1846Z" fill="#EB5829" />
<path
d="M19.6362 17.5793H23.9998V21.9742H19.6362V17.5793ZM2.18164 17.5793H6.54523V21.9742H2.18164V17.5793Z"
fill="#EA3326"
/>
</g>
<defs>
<clipPath id="clip0_1621_58">
<rect width="24" height="22" fill="white" />
</clipPath>
</defs>
</svg>
)
}
+4 -2
View File
@@ -20,6 +20,7 @@ import { guestyGuestTool, guestyReservationTool } from './guesty'
import { requestTool as httpRequest } from './http/request'
import { contactsTool as hubspotContacts } from './hubspot/contacts'
import { readUrlTool } from './jina/reader'
import { mistralParserTool } from './mistral'
import { notionReadTool, notionWriteTool } from './notion'
import { dalleTool } from './openai/dalle'
import { embeddingsTool as openAIEmbeddings } from './openai/embeddings'
@@ -116,6 +117,7 @@ export const tools: Record<string, ToolConfig> = {
airtable_read: airtableReadTool,
airtable_write: airtableWriteTool,
airtable_update: airtableUpdateTool,
mistral_parser: mistralParserTool,
}
// Get a tool by its ID
@@ -295,7 +297,7 @@ function getCustomTool(customToolId: string): ToolConfig | undefined {
},
// Response handling
transformResponse: async (response: Response) => {
transformResponse: async (response: Response, params: Record<string, any>) => {
const data = await response.json()
if (!data.success) {
@@ -597,7 +599,7 @@ async function handleInternalRequest(
// Use the tool's response transformer if available
if (tool.transformResponse) {
return await tool.transformResponse(response)
return await tool.transformResponse(response, params)
}
// Default response handling
+3
View File
@@ -0,0 +1,3 @@
import { mistralParserTool } from './parser'
export { mistralParserTool }
+469
View File
@@ -0,0 +1,469 @@
import { ToolConfig, ToolResponse } from '../types'
/**
* Input parameters for the Mistral OCR parser tool
*/
export interface MistralParserInput {
/** URL to a PDF document to be processed */
filePath: string;
/** Mistral API key for authentication */
apiKey: string;
/** Output format for the extracted content (default: 'markdown') */
resultType?: 'markdown' | 'text' | 'json';
/** Whether to include base64-encoded images in the response */
includeImageBase64?: boolean;
/** Specific pages to process (zero-indexed) */
pages?: number[];
/** Maximum number of images to extract from the PDF */
imageLimit?: number;
/** Minimum height and width (in pixels) for images to extract */
imageMinSize?: number;
}
/**
* Usage information returned by the Mistral OCR API
*/
export interface MistralOcrUsageInfo {
/** Number of pages processed in the document */
pagesProcessed: number;
/** Size of the document in bytes */
docSizeBytes: number;
}
/**
* Metadata about the processed document
*/
export interface MistralParserMetadata {
/** Unique identifier for this OCR job */
jobId: string;
/** File type of the document (typically 'pdf') */
fileType: string;
/** Filename extracted from the document URL */
fileName: string;
/** Source type (always 'url' for now) */
source: 'url';
/** Original URL to the document */
sourceUrl: string;
/** Total number of pages in the document */
pageCount: number;
/** Usage statistics from the OCR processing */
usageInfo?: MistralOcrUsageInfo;
/** The Mistral OCR model used for processing */
model: string;
/** The output format that was requested */
resultType?: 'markdown' | 'text' | 'json';
/** ISO timestamp when the document was processed */
processedAt: string;
}
/**
* Output data structure from the Mistral OCR parser
*/
export interface MistralParserOutputData {
/** Extracted content in the requested format */
content: string;
/** Metadata about the parsed document and processing */
metadata: MistralParserMetadata;
}
/**
* Complete response from the Mistral OCR parser tool
*/
export interface MistralParserOutput extends ToolResponse {
/** The output data containing content and metadata */
output: MistralParserOutputData;
}
export const mistralParserTool: ToolConfig<MistralParserInput, MistralParserOutput> = {
id: 'mistral_parser',
name: 'Mistral PDF Parser',
description: 'Parse PDF documents using Mistral OCR API',
version: '1.0.0',
params: {
filePath: {
type: 'string',
required: true,
description: 'URL to a PDF document to be processed',
},
resultType: {
type: 'string',
required: false,
description: 'Type of parsed result (markdown, text, or json). Defaults to markdown.',
},
apiKey: {
type: 'string',
required: true,
requiredForToolCall: true,
description: 'Mistral API key (MISTRAL_API_KEY)',
},
includeImageBase64: {
type: 'boolean',
required: false,
description: 'Include base64-encoded images in the response',
},
pages: {
type: 'array',
required: false,
description: 'Specific pages to process (array of page numbers, starting from 0)',
},
// Note: The following image-related parameters are still supported by the parser
// but are disabled in the UI. They can be re-enabled if needed.
imageLimit: {
type: 'number',
required: false,
description: 'Maximum number of images to extract from the PDF',
},
imageMinSize: {
type: 'number',
required: false,
description: 'Minimum height and width of images to extract from the PDF',
},
},
request: {
url: 'https://api.mistral.ai/v1/ocr',
method: 'POST',
headers: (params) => {
console.log('Setting up headers with API key:', params.apiKey ? `${params.apiKey.substring(0, 5)}...` : 'Missing');
return {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${params.apiKey}`,
};
},
body: (params) => {
if (!params || typeof params !== 'object') {
throw new Error('Invalid parameters: Parameters must be provided as an object');
}
// Validate required parameters
if (!params.apiKey || typeof params.apiKey !== 'string' || params.apiKey.trim() === '') {
throw new Error('Missing or invalid API key: A valid Mistral API key is required');
}
if (!params.filePath || typeof params.filePath !== 'string' || params.filePath.trim() === '') {
throw new Error('Missing or invalid file path: Please provide a URL to a PDF document');
}
// Validate and normalize URL
let url;
try {
url = new URL(params.filePath.trim());
// Validate protocol
if (!['http:', 'https:'].includes(url.protocol)) {
throw new Error(`Invalid protocol: ${url.protocol}. URL must use HTTP or HTTPS protocol`);
}
// Validate file appears to be a PDF (loose check)
const pathname = url.pathname.toLowerCase();
if (!pathname.endsWith('.pdf') && !pathname.includes('pdf')) {
console.warn(
'Warning: URL does not appear to be a PDF document. ' +
'If this is incorrect, the document may still be processed if it is a valid PDF.'
);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(
`Invalid URL format: ${errorMessage}. ` +
'Please provide a valid HTTP or HTTPS URL to a PDF document (e.g., https://example.com/document.pdf)'
);
}
// Create the request body with required parameters
const requestBody: Record<string, any> = {
model: "mistral-ocr-latest",
document: {
type: "document_url",
document_url: url.toString()
}
};
// Add optional parameters with proper validation
// Include images (base64)
if (params.includeImageBase64 !== undefined) {
if (typeof params.includeImageBase64 !== 'boolean') {
console.warn('includeImageBase64 parameter should be a boolean, using default (false)');
} else {
requestBody.include_image_base64 = params.includeImageBase64;
}
}
// Page selection
if (params.pages !== undefined) {
if (Array.isArray(params.pages) && params.pages.length > 0) {
// Validate all page numbers are non-negative integers
const validPages = params.pages.filter(
(page) => typeof page === 'number' && Number.isInteger(page) && page >= 0
);
if (validPages.length > 0) {
requestBody.pages = validPages;
if (validPages.length !== params.pages.length) {
console.warn(
`Some invalid page numbers were removed. ` +
`Using ${validPages.length} valid pages: ${validPages.join(', ')}`
);
}
} else {
console.warn('No valid page numbers provided, processing all pages');
}
} else if (params.pages.length === 0) {
console.warn('Empty pages array provided, processing all pages');
}
}
// Image limit
if (params.imageLimit !== undefined) {
const imageLimit = Number(params.imageLimit);
if (Number.isInteger(imageLimit) && imageLimit > 0) {
requestBody.image_limit = imageLimit;
} else {
console.warn('imageLimit must be a positive integer, ignoring this parameter');
}
}
// Minimum image size
if (params.imageMinSize !== undefined) {
const imageMinSize = Number(params.imageMinSize);
if (Number.isInteger(imageMinSize) && imageMinSize > 0) {
requestBody.image_min_size = imageMinSize;
} else {
console.warn('imageMinSize must be a positive integer, ignoring this parameter');
}
}
// Log the request (with sensitive data redacted)
console.log('Mistral OCR request:', {
url: url.toString(),
hasApiKey: !!params.apiKey,
model: requestBody.model,
options: {
includesImages: requestBody.include_image_base64 ?? 'not specified',
pages: requestBody.pages ?? 'all pages',
imageLimit: requestBody.image_limit ?? 'no limit',
imageMinSize: requestBody.image_min_size ?? 'no minimum',
}
});
return requestBody;
},
},
transformResponse: async (response, params?) => {
try {
// Verify response status
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Mistral OCR API error: ${response.status} ${response.statusText}${errorText ? ` - ${errorText}` : ''}`);
}
// Parse response data with proper error handling
let ocrResult;
try {
ocrResult = await response.json();
} catch (jsonError) {
throw new Error(`Failed to parse Mistral OCR response: ${jsonError instanceof Error ? jsonError.message : String(jsonError)}`);
}
if (!ocrResult || typeof ocrResult !== 'object') {
throw new Error('Invalid response format from Mistral OCR API');
}
// Set default values and extract from params if available
let resultType: 'markdown' | 'text' | 'json' = 'markdown';
let sourceUrl = '';
if (params && typeof params === 'object') {
if (params.filePath && typeof params.filePath === 'string') {
sourceUrl = params.filePath.trim();
}
if (params.resultType && ['markdown', 'text', 'json'].includes(params.resultType)) {
resultType = params.resultType as 'markdown' | 'text' | 'json';
}
} else if (ocrResult.document && typeof ocrResult.document === 'object' &&
ocrResult.document.document_url && typeof ocrResult.document.document_url === 'string') {
sourceUrl = ocrResult.document.document_url;
}
// Process content from pages
let content = '';
const pageCount = ocrResult.pages && Array.isArray(ocrResult.pages) ? ocrResult.pages.length : 0;
if (pageCount > 0) {
content = ocrResult.pages
.map((page: any) => (page && typeof page.markdown === 'string') ? page.markdown : '')
.filter(Boolean)
.join('\n\n');
} else {
console.warn('No pages found in OCR result, returning raw response');
content = JSON.stringify(ocrResult, null, 2);
}
// Process based on requested result type
if (resultType === 'text') {
// Strip markdown formatting
content = content
.replace(/\#\#*\s/g, '') // Remove markdown headers
.replace(/\*\*/g, '') // Remove bold markers
.replace(/\*/g, '') // Remove italic markers
.replace(/\n{3,}/g, '\n\n'); // Normalize newlines
} else if (resultType === 'json') {
// Return the structured data as JSON string
content = JSON.stringify(ocrResult, null, 2);
}
// Extract file information with proper validation
let fileName = 'document.pdf';
let fileType = 'pdf';
if (sourceUrl) {
try {
const url = new URL(sourceUrl);
const pathSegments = url.pathname.split('/');
const lastSegment = pathSegments[pathSegments.length - 1];
if (lastSegment && lastSegment.length > 0) {
fileName = lastSegment;
const fileExtParts = fileName.split('.');
if (fileExtParts.length > 1) {
fileType = fileExtParts[fileExtParts.length - 1].toLowerCase();
}
}
} catch (urlError) {
console.warn('Failed to parse document URL:', urlError);
}
}
// Generate a tracking ID with timestamp and random component for uniqueness
const timestamp = Date.now();
const randomId = Math.random().toString(36).substring(2, 10);
const jobId = `mistral-ocr-${timestamp}-${randomId}`;
// Map API response fields to our schema with proper type checking
const usageInfo = ocrResult.usage_info && typeof ocrResult.usage_info === 'object'
? {
pagesProcessed: typeof ocrResult.usage_info.pages_processed === 'number'
? ocrResult.usage_info.pages_processed
: Number(ocrResult.usage_info.pages_processed),
docSizeBytes: typeof ocrResult.usage_info.doc_size_bytes === 'number'
? ocrResult.usage_info.doc_size_bytes
: Number(ocrResult.usage_info.doc_size_bytes)
}
: undefined;
// Return properly structured response
const parserResponse: MistralParserOutput = {
success: true,
output: {
content,
metadata: {
jobId,
fileType,
fileName,
source: 'url',
sourceUrl,
pageCount,
usageInfo,
model: typeof ocrResult.model === 'string' ? ocrResult.model : 'mistral-ocr-latest',
resultType,
processedAt: new Date().toISOString(),
},
},
};
return parserResponse;
} catch (error) {
console.error('Error processing OCR result:', error);
throw error;
}
},
transformError: (error) => {
console.error('Mistral OCR processing error:', error);
// Helper function to extract message from various error types
const getErrorMessage = (err: any): string => {
if (typeof err === 'string') return err;
if (err instanceof Error) return err.message;
if (err && typeof err === 'object') {
if (err.message) return String(err.message);
if (err.error) return typeof err.error === 'string' ? err.error : JSON.stringify(err.error);
}
return 'Unknown error';
};
// Get base error message
const errorMsg = getErrorMessage(error);
// Handle common API error status codes
if (typeof error === 'object' && error !== null) {
const status = error.status || (error.response && error.response.status);
if (status) {
switch (status) {
case 400:
return 'Mistral OCR Error: The request was invalid. Please check your PDF URL and parameters.';
case 401:
return 'Mistral OCR Error: Invalid API key. Please check your Mistral API key.';
case 403:
return 'Mistral OCR Error: Access forbidden. Your API key may not have permission to use the OCR service.';
case 404:
return 'Mistral OCR Error: The PDF document could not be found. Please check that the URL is accessible.';
case 413:
return 'Mistral OCR Error: The PDF document is too large for processing.';
case 415:
return 'Mistral OCR Error: Unsupported file format. Please ensure the URL points to a valid PDF document.';
case 429:
return 'Mistral OCR Error: Rate limit exceeded. Please try again later.';
case 500:
case 502:
case 503:
case 504:
return 'Mistral OCR Error: Service temporarily unavailable. Please try again later.';
}
}
}
// Handle common network and URL errors
if (errorMsg.includes('URL') || errorMsg.includes('protocol') || errorMsg.includes('http')) {
return 'Mistral OCR Error: Invalid PDF URL format. Please provide a complete URL starting with https:// to your PDF document.';
}
if (errorMsg.includes('ETIMEDOUT') || errorMsg.includes('timeout') || errorMsg.includes('ECONNABORTED')) {
return 'Mistral OCR Error: The request timed out. The PDF document may be too large or the server is unresponsive.';
}
if (errorMsg.includes('ENOTFOUND') || errorMsg.includes('ECONNREFUSED') || errorMsg.includes('ECONNRESET')) {
return 'Mistral OCR Error: Could not connect to the document URL. Please verify the document is accessible.';
}
if (errorMsg.includes('JSON') || errorMsg.includes('Unexpected token') || errorMsg.includes('parse')) {
return 'Mistral OCR Error: Failed to parse the response from the OCR service.';
}
// Default error message with the original error for context
return `Mistral OCR Error: ${errorMsg}`;
},
}