mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-27 12:54:09 +01:00
* ✨ feat: Add support for OpenDocument MIME types in file configuration
Updated the applicationMimeTypes regex to include support for OASIS OpenDocument formats, enhancing the file type recognition capabilities of the data provider.
* feat: document processing with OpenDocument support
Added support for OpenDocument Spreadsheet (ODS) MIME type in the file processing service and updated the document parser to handle ODS files. Included tests to verify correct parsing of ODS documents and updated file configuration to recognize OpenDocument formats.
* refactor: Enhance document processing to support additional Excel MIME types
Updated the document processing logic to utilize a regex for matching Excel MIME types, improving flexibility in handling various Excel file formats. Added tests to ensure correct parsing of new MIME types, including multiple Excel variants and OpenDocument formats. Adjusted file configuration to include these MIME types for better recognition in the file processing service.
* feat: Add support for additional OpenDocument MIME types in file processing
Enhanced the document processing service to support ODT, ODP, and ODG MIME types. Updated tests to verify correct routing through the OCR strategy for these new formats. Adjusted documentation to reflect changes in handled MIME types for improved clarity.
347 lines
13 KiB
JavaScript
347 lines
13 KiB
JavaScript
jest.mock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid') }));
|
|
|
|
jest.mock('@librechat/data-schemas', () => ({
|
|
logger: { warn: jest.fn(), debug: jest.fn(), error: jest.fn() },
|
|
}));
|
|
|
|
jest.mock('@librechat/agents', () => ({
|
|
EnvVar: { CODE_API_KEY: 'CODE_API_KEY' },
|
|
}));
|
|
|
|
jest.mock('@librechat/api', () => ({
|
|
sanitizeFilename: jest.fn((n) => n),
|
|
parseText: jest.fn().mockResolvedValue({ text: '', bytes: 0 }),
|
|
processAudioFile: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('librechat-data-provider', () => ({
|
|
...jest.requireActual('librechat-data-provider'),
|
|
mergeFileConfig: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Files/images', () => ({
|
|
convertImage: jest.fn(),
|
|
resizeAndConvert: jest.fn(),
|
|
resizeImageBuffer: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/controllers/assistants/v2', () => ({
|
|
addResourceFileId: jest.fn(),
|
|
deleteResourceFileId: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/models/Agent', () => ({
|
|
addAgentResourceFile: jest.fn().mockResolvedValue({}),
|
|
removeAgentResourceFiles: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/controllers/assistants/helpers', () => ({
|
|
getOpenAIClient: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Tools/credentials', () => ({
|
|
loadAuthValues: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/models', () => ({
|
|
createFile: jest.fn().mockResolvedValue({ file_id: 'created-file-id' }),
|
|
updateFileUsage: jest.fn(),
|
|
deleteFiles: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/utils/getFileStrategy', () => ({
|
|
getFileStrategy: jest.fn().mockReturnValue('local'),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Config', () => ({
|
|
checkCapability: jest.fn().mockResolvedValue(true),
|
|
}));
|
|
|
|
jest.mock('~/server/utils/queue', () => ({
|
|
LB_QueueAsyncCall: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Files/strategies', () => ({
|
|
getStrategyFunctions: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/utils', () => ({
|
|
determineFileType: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Files/Audio/STTService', () => ({
|
|
STTService: { getInstance: jest.fn() },
|
|
}));
|
|
|
|
const { EToolResources, FileSources, AgentCapabilities } = require('librechat-data-provider');
|
|
const { mergeFileConfig } = require('librechat-data-provider');
|
|
const { checkCapability } = require('~/server/services/Config');
|
|
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
|
const { processAgentFileUpload } = require('./process');
|
|
|
|
const PDF_MIME = 'application/pdf';
|
|
const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
|
const XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
|
const XLS_MIME = 'application/vnd.ms-excel';
|
|
const ODS_MIME = 'application/vnd.oasis.opendocument.spreadsheet';
|
|
const ODT_MIME = 'application/vnd.oasis.opendocument.text';
|
|
const ODP_MIME = 'application/vnd.oasis.opendocument.presentation';
|
|
const ODG_MIME = 'application/vnd.oasis.opendocument.graphics';
|
|
|
|
const makeReq = ({ mimetype = PDF_MIME, ocrConfig = null } = {}) => ({
|
|
user: { id: 'user-123' },
|
|
file: {
|
|
path: '/tmp/upload.bin',
|
|
originalname: 'upload.bin',
|
|
filename: 'upload-uuid.bin',
|
|
mimetype,
|
|
},
|
|
body: { model: 'gpt-4o' },
|
|
config: {
|
|
fileConfig: {},
|
|
fileStrategy: 'local',
|
|
ocr: ocrConfig,
|
|
},
|
|
});
|
|
|
|
const makeMetadata = () => ({
|
|
agent_id: 'agent-abc',
|
|
tool_resource: EToolResources.context,
|
|
file_id: 'file-uuid-123',
|
|
});
|
|
|
|
const mockRes = {
|
|
status: jest.fn().mockReturnThis(),
|
|
json: jest.fn().mockReturnValue({}),
|
|
};
|
|
|
|
const makeFileConfig = ({ ocrSupportedMimeTypes = [] } = {}) => ({
|
|
checkType: (mime, types) => (types ?? []).includes(mime),
|
|
ocr: { supportedMimeTypes: ocrSupportedMimeTypes },
|
|
stt: { supportedMimeTypes: [] },
|
|
text: { supportedMimeTypes: [] },
|
|
});
|
|
|
|
describe('processAgentFileUpload', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockRes.status.mockReturnThis();
|
|
mockRes.json.mockReturnValue({});
|
|
checkCapability.mockResolvedValue(true);
|
|
getStrategyFunctions.mockReturnValue({
|
|
handleFileUpload: jest
|
|
.fn()
|
|
.mockResolvedValue({ text: 'extracted text', bytes: 42, filepath: 'doc://result' }),
|
|
});
|
|
mergeFileConfig.mockReturnValue(makeFileConfig());
|
|
});
|
|
|
|
describe('OCR strategy selection', () => {
|
|
test.each([
|
|
['PDF', PDF_MIME],
|
|
['DOCX', DOCX_MIME],
|
|
['XLSX', XLSX_MIME],
|
|
['XLS', XLS_MIME],
|
|
['ODS', ODS_MIME],
|
|
['Excel variant (msexcel)', 'application/msexcel'],
|
|
['Excel variant (x-msexcel)', 'application/x-msexcel'],
|
|
])('uses document_parser automatically for %s when no OCR is configured', async (_, mime) => {
|
|
mergeFileConfig.mockReturnValue(makeFileConfig());
|
|
const req = makeReq({ mimetype: mime, ocrConfig: null });
|
|
|
|
await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
|
|
|
|
expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser);
|
|
});
|
|
|
|
test('does not check OCR capability when using automatic document_parser fallback', async () => {
|
|
const req = makeReq({ mimetype: PDF_MIME, ocrConfig: null });
|
|
|
|
await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
|
|
|
|
expect(checkCapability).not.toHaveBeenCalledWith(expect.anything(), AgentCapabilities.ocr);
|
|
expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser);
|
|
});
|
|
|
|
test('uses the configured OCR strategy when OCR is set up for the file type', async () => {
|
|
mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [PDF_MIME] }));
|
|
const req = makeReq({
|
|
mimetype: PDF_MIME,
|
|
ocrConfig: { strategy: FileSources.mistral_ocr },
|
|
});
|
|
|
|
await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
|
|
|
|
expect(checkCapability).toHaveBeenCalledWith(expect.anything(), AgentCapabilities.ocr);
|
|
expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.mistral_ocr);
|
|
});
|
|
|
|
test('uses document_parser as default when OCR is configured but no strategy is specified', async () => {
|
|
mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [PDF_MIME] }));
|
|
const req = makeReq({
|
|
mimetype: PDF_MIME,
|
|
ocrConfig: { supportedMimeTypes: [PDF_MIME] },
|
|
});
|
|
|
|
await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
|
|
|
|
expect(checkCapability).toHaveBeenCalledWith(expect.anything(), AgentCapabilities.ocr);
|
|
expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser);
|
|
});
|
|
|
|
test('throws when configured OCR capability is not enabled for the agent', async () => {
|
|
mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [PDF_MIME] }));
|
|
checkCapability.mockResolvedValue(false);
|
|
const req = makeReq({
|
|
mimetype: PDF_MIME,
|
|
ocrConfig: { strategy: FileSources.mistral_ocr },
|
|
});
|
|
|
|
await expect(
|
|
processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }),
|
|
).rejects.toThrow('OCR capability is not enabled for Agents');
|
|
});
|
|
|
|
test('uses document_parser (no capability check) when OCR capability returns false but no OCR config', async () => {
|
|
checkCapability.mockResolvedValue(false);
|
|
const req = makeReq({ mimetype: PDF_MIME, ocrConfig: null });
|
|
|
|
await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
|
|
|
|
expect(checkCapability).not.toHaveBeenCalledWith(expect.anything(), AgentCapabilities.ocr);
|
|
expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser);
|
|
});
|
|
|
|
test('uses document_parser when OCR is configured but the file type is not in OCR supported types', async () => {
|
|
mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [PDF_MIME] }));
|
|
const req = makeReq({
|
|
mimetype: DOCX_MIME,
|
|
ocrConfig: { strategy: FileSources.mistral_ocr },
|
|
});
|
|
|
|
await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
|
|
|
|
expect(checkCapability).not.toHaveBeenCalledWith(expect.anything(), AgentCapabilities.ocr);
|
|
expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser);
|
|
expect(getStrategyFunctions).not.toHaveBeenCalledWith(FileSources.mistral_ocr);
|
|
});
|
|
|
|
test('does not invoke any OCR strategy for unsupported MIME types without OCR config', async () => {
|
|
const req = makeReq({ mimetype: 'text/plain', ocrConfig: null });
|
|
|
|
await expect(
|
|
processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }),
|
|
).rejects.toThrow('File type text/plain is not supported for text parsing.');
|
|
|
|
expect(getStrategyFunctions).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test.each([
|
|
['ODT', ODT_MIME],
|
|
['ODP', ODP_MIME],
|
|
['ODG', ODG_MIME],
|
|
])('routes %s through configured OCR when OCR supports the type', async (_, mime) => {
|
|
mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [mime] }));
|
|
const req = makeReq({
|
|
mimetype: mime,
|
|
ocrConfig: { strategy: FileSources.mistral_ocr },
|
|
});
|
|
|
|
await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
|
|
|
|
expect(checkCapability).toHaveBeenCalledWith(expect.anything(), AgentCapabilities.ocr);
|
|
expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.mistral_ocr);
|
|
});
|
|
|
|
test('throws instead of falling back to parseText when document_parser fails for a document MIME type', async () => {
|
|
getStrategyFunctions.mockReturnValue({
|
|
handleFileUpload: jest.fn().mockRejectedValue(new Error('No text found in document')),
|
|
});
|
|
const req = makeReq({ mimetype: PDF_MIME, ocrConfig: null });
|
|
const { parseText } = require('@librechat/api');
|
|
|
|
await expect(
|
|
processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }),
|
|
).rejects.toThrow(/image-based and requires an OCR service/);
|
|
|
|
expect(parseText).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('falls back to document_parser when configured OCR fails for a document MIME type', async () => {
|
|
mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [PDF_MIME] }));
|
|
const failingUpload = jest.fn().mockRejectedValue(new Error('OCR API returned 500'));
|
|
const fallbackUpload = jest
|
|
.fn()
|
|
.mockResolvedValue({ text: 'parsed text', bytes: 11, filepath: 'doc://result' });
|
|
getStrategyFunctions
|
|
.mockReturnValueOnce({ handleFileUpload: failingUpload })
|
|
.mockReturnValueOnce({ handleFileUpload: fallbackUpload });
|
|
const req = makeReq({
|
|
mimetype: PDF_MIME,
|
|
ocrConfig: { strategy: FileSources.mistral_ocr },
|
|
});
|
|
|
|
await expect(
|
|
processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }),
|
|
).resolves.not.toThrow();
|
|
|
|
expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.mistral_ocr);
|
|
expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser);
|
|
});
|
|
|
|
test('throws when both configured OCR and document_parser fallback fail', async () => {
|
|
mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [PDF_MIME] }));
|
|
getStrategyFunctions.mockReturnValue({
|
|
handleFileUpload: jest.fn().mockRejectedValue(new Error('failure')),
|
|
});
|
|
const req = makeReq({
|
|
mimetype: PDF_MIME,
|
|
ocrConfig: { strategy: FileSources.mistral_ocr },
|
|
});
|
|
const { parseText } = require('@librechat/api');
|
|
|
|
await expect(
|
|
processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }),
|
|
).rejects.toThrow(/image-based and requires an OCR service/);
|
|
|
|
expect(parseText).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('text size guard', () => {
|
|
test('throws before writing to MongoDB when extracted text exceeds 15MB', async () => {
|
|
const oversizedText = 'x'.repeat(15 * 1024 * 1024 + 1);
|
|
getStrategyFunctions.mockReturnValue({
|
|
handleFileUpload: jest.fn().mockResolvedValue({
|
|
text: oversizedText,
|
|
bytes: Buffer.byteLength(oversizedText, 'utf8'),
|
|
filepath: 'doc://result',
|
|
}),
|
|
});
|
|
const req = makeReq({ mimetype: PDF_MIME, ocrConfig: null });
|
|
const { createFile } = require('~/models');
|
|
|
|
await expect(
|
|
processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }),
|
|
).rejects.toThrow(/exceeds the 15MB storage limit/);
|
|
|
|
expect(createFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('succeeds when extracted text is within the 15MB limit', async () => {
|
|
const okText = 'x'.repeat(1024);
|
|
getStrategyFunctions.mockReturnValue({
|
|
handleFileUpload: jest.fn().mockResolvedValue({
|
|
text: okText,
|
|
bytes: Buffer.byteLength(okText, 'utf8'),
|
|
filepath: 'doc://result',
|
|
}),
|
|
});
|
|
const req = makeReq({ mimetype: PDF_MIME, ocrConfig: null });
|
|
|
|
await expect(
|
|
processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }),
|
|
).resolves.not.toThrow();
|
|
});
|
|
});
|
|
});
|