diff --git a/CHANGELOG.md b/CHANGELOG.md index a2350dd..2fb52fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to - ✨(back) add projects with custom LLM instructions - ✨(front) projects management UI +- ✨(back) add ODT parsing support ### Changed diff --git a/src/backend/chat/agent_rag/document_converter/markitdown.py b/src/backend/chat/agent_rag/document_converter/markitdown.py index 78562fa..747fee2 100644 --- a/src/backend/chat/agent_rag/document_converter/markitdown.py +++ b/src/backend/chat/agent_rag/document_converter/markitdown.py @@ -39,5 +39,4 @@ class DocumentConverter: conversion = self.converter.convert_stream( document, file_extension=file_extension or ".txt" ) - document_markdown = conversion.text_content - return document_markdown + return conversion.text_content diff --git a/src/backend/chat/agent_rag/document_converter/odt.py b/src/backend/chat/agent_rag/document_converter/odt.py new file mode 100644 index 0000000..ab0e5c4 --- /dev/null +++ b/src/backend/chat/agent_rag/document_converter/odt.py @@ -0,0 +1,31 @@ +"""ODT Document Converter using odfdo""" + +import logging +import zipfile +from io import BytesIO + +from django.utils.translation import gettext_lazy as _ + +from lxml.etree import XMLSyntaxError # pylint: disable=no-name-in-module +from odfdo import Document + +logger = logging.getLogger(__name__) + + +class OdtParsingError(Exception): + """Raised when an ODT file cannot be parsed.""" + + +class OdtToMd: + """Convert an ODT file to Markdown using odfdo.""" + + def extract(self, content: bytes, **kwargs) -> str: + """Extract markdown from odt""" + try: + doc = Document(BytesIO(content)) + return doc.to_markdown() + except (TypeError, FileNotFoundError, zipfile.BadZipFile, XMLSyntaxError) as e: + logger.error("Failed to parse ODT document: %s", e) + raise OdtParsingError( + _("Failed to parse ODT document: %(error)s") % {"error": e} + ) from e diff --git a/src/backend/chat/agent_rag/document_converter/parser.py b/src/backend/chat/agent_rag/document_converter/parser.py index 509a3b8..8f3be6d 100644 --- a/src/backend/chat/agent_rag/document_converter/parser.py +++ b/src/backend/chat/agent_rag/document_converter/parser.py @@ -13,31 +13,53 @@ from pypdf import PdfReader, PdfWriter from chat.agent_rag.document_converter.markitdown import DocumentConverter +from .odt import OdtToMd + logger = logging.getLogger(__name__) +CT_PDF = "application/pdf" +CT_ODT = "application/vnd.oasis.opendocument.text" + class BaseParser: - """Base class for document parsers.""" + """Base class for document parsers. + + Routes documents by content type: + - PDF -> self.parse_pdf_document() (must be provided by subclass or mixin) + - ODT -> self.parse_odt_document() (must be provided by subclass or mixin) + - Other -> DocumentConverter (markitdown) + """ def parse_document(self, name: str, content_type: str, content: bytes) -> str: - """ - Parse the document and prepare it for the search operation. - This method should handle the logic to convert the document - into a format suitable for storage. + """Route to the appropriate parser based on content type.""" - Args: - name (str): The name of the document. - content_type (str): The MIME type of the document (e.g., "application/pdf"). - content (bytes): The content of the document as a bytes stream. + if content_type == CT_PDF: + return self.parse_pdf_document(name=name, content_type=content_type, content=content) + if content_type == CT_ODT: + return self.parse_odt_document(content=content) + return DocumentConverter().convert_raw( + name=name, content_type=content_type, content=content + ) - Returns: - str: The document content in Markdown format. - """ + def parse_pdf_document(self, name: str, content_type: str, content: bytes) -> str: + """Parse PDF document. Must be implemented by subclass or mixin.""" + raise NotImplementedError("Must be implemented in subclass.") + + def parse_odt_document(self, content: bytes) -> str: + """Parse ODT document. Must be implemented by subclass or mixin.""" raise NotImplementedError("Must be implemented in subclass.") -class AlbertParser(BaseParser): - """Document parser using Albert API for PDFs and DocumentConverter for other formats.""" +class OdtParserMixin: + """Mixin that adds ODT parsing using odfdo.""" + + def parse_odt_document(self, content: bytes) -> str: + """Parse ODT document using ofdo util.""" + return OdtToMd().extract(content) + + +class AlbertParser(OdtParserMixin, BaseParser): + """Document parser using Albert API for PDFs.""" endpoint = urljoin(settings.ALBERT_API_URL, "/v1/parse-beta") @@ -60,23 +82,13 @@ class AlbertParser(BaseParser): document_page["content"] for document_page in response.json().get("data", []) ) - def parse_document(self, name: str, content_type: str, content: bytes) -> str: - """Parse document based on content type.""" - if content_type == "application/pdf": - return self.parse_pdf_document(name=name, content_type=content_type, content=content) - return DocumentConverter().convert_raw( - name=name, content_type=content_type, content=content - ) - METHOD_TEXT_EXTRACTION = "text_extraction" METHOD_OCR = "ocr" def analyze_pdf(pdf_data: bytes) -> dict: - """ - Analyze a PDF to determine if it needs OCR or can use direct text extraction. - """ + """Analyze a PDF to determine if it needs OCR or can use direct text extraction.""" reader = PdfReader(BytesIO(pdf_data)) total_pages = len(reader.pages) if total_pages == 0: @@ -95,20 +107,17 @@ def analyze_pdf(pdf_data: bytes) -> dict: text = (page.extract_text() or "").strip() char_count = len(text) total_chars += char_count - if char_count > 50: pages_with_text += 1 avg_chars = total_chars / total_pages text_coverage = pages_with_text / total_pages - # Decision logic if ( avg_chars > settings.MIN_AVG_CHARS_FOR_TEXT_EXTRACTION and text_coverage > settings.MIN_TEXT_COVERAGE_FOR_TEXT_EXTRACTION ): method = METHOD_TEXT_EXTRACTION - else: method = METHOD_OCR @@ -121,7 +130,7 @@ def analyze_pdf(pdf_data: bytes) -> dict: } -class AdaptiveParserMixin: +class AdaptivePdfParserMixin: """ Mixin that adds adaptive PDF parsing behavior. @@ -159,7 +168,7 @@ class AdaptiveParserMixin: raise NotImplementedError("Subclass must implement parse_pdf_document_with_ocr") -class AdaptivePdfParser(AdaptiveParserMixin, BaseParser): +class AdaptivePdfParser(AdaptivePdfParserMixin, OdtParserMixin, BaseParser): """ PDF parser with adaptive text extraction / OCR routing. @@ -265,16 +274,6 @@ class AdaptivePdfParser(AdaptiveParserMixin, BaseParser): ) except Exception as e: # pylint: disable=broad-except #noqa: BLE001 logger.error("Failed to OCR pages %d-%d: %s", start_index + 1, end_index, str(e)) - # Preserve page count with empty placeholders to maintain correct ordering results.extend([""] * (end_index - start_index)) return "\n\n".join(results) - - def parse_document(self, name: str, content_type: str, content: bytes) -> str: - """Route to PDF parser or DocumentConverter based on content type.""" - if content_type == "application/pdf": - return self.parse_pdf_document(name=name, content_type=content_type, content=content) - - return DocumentConverter().convert_raw( - name=name, content_type=content_type, content=content - ) diff --git a/src/backend/chat/tests/agent_rag/document_converter/fixtures/sample.odt b/src/backend/chat/tests/agent_rag/document_converter/fixtures/sample.odt new file mode 100644 index 0000000..fa124b0 Binary files /dev/null and b/src/backend/chat/tests/agent_rag/document_converter/fixtures/sample.odt differ diff --git a/src/backend/chat/tests/agent_rag/document_converter/test_adaptive_pdf_parser.py b/src/backend/chat/tests/agent_rag/document_converter/test_adaptive_pdf_parser.py index 4d2de47..599e1d9 100644 --- a/src/backend/chat/tests/agent_rag/document_converter/test_adaptive_pdf_parser.py +++ b/src/backend/chat/tests/agent_rag/document_converter/test_adaptive_pdf_parser.py @@ -8,6 +8,7 @@ import pytest import requests from pypdf import PdfReader +from chat.agent_rag.document_converter.odt import OdtParsingError from chat.agent_rag.document_converter.parser import ( METHOD_OCR, METHOD_TEXT_EXTRACTION, @@ -36,6 +37,12 @@ def provide_mixed_pdf_10_pages(): return (FIXTURES_DIR / "mixed_10_pages.pdf").read_bytes() +@pytest.fixture(name="sample_odt") +def provide_sample_odt(): + """Load an ODT document.""" + return (FIXTURES_DIR / "sample.odt").read_bytes() + + MIN_AVG_CHARS_FOR_TEXT_EXTRACTION = 200 OCR_RETRY_DELAY = 1 OCR_MAX_RETRIES = 3 @@ -297,6 +304,63 @@ def test_parse_document_pdf_routed_correctly(text_pdf_1_page): ) +def test_text_pdf_routed_to_text_extraction(text_pdf_10_pages): + """Text-rich PDF should be routed to extract_text_from_pdf, not OCR.""" + parser = AdaptivePdfParser() + + with ( + patch.object(parser, "extract_text_from_pdf", return_value="extracted") as mock_extract, + patch.object(parser, "parse_pdf_document_with_ocr") as mock_ocr, + ): + result = parser.parse_pdf_document( + name="test.pdf", content_type="application/pdf", content=text_pdf_10_pages + ) + + assert result == "extracted" + mock_extract.assert_called_once_with( + name="test.pdf", content_type="application/pdf", content=text_pdf_10_pages + ) + mock_ocr.assert_not_called() + + +def test_mixed_pdf_routed_to_ocr(mixed_pdf_10_pages): + """PDF with low text coverage should be routed to OCR, not text extraction.""" + parser = AdaptivePdfParser() + + with ( + patch.object(parser, "extract_text_from_pdf") as mock_extract, + patch.object(parser, "parse_pdf_document_with_ocr", return_value="ocr result") as mock_ocr, + ): + result = parser.parse_pdf_document( + name="test.pdf", content_type="application/pdf", content=mixed_pdf_10_pages + ) + + assert result == "ocr result" + mock_ocr.assert_called_once_with(name="test.pdf", content=mixed_pdf_10_pages) + mock_extract.assert_not_called() + + +def test_parse_document_pdf(text_pdf_1_page): + """Should route PDF content type to PDF parser.""" + parser = AdaptivePdfParser() + + result = parser.parse_document("test.pdf", "application/pdf", text_pdf_1_page) + + assert result == ( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor " + "incididunt ut\nlabore et dolore magna aliqua. Ut enim ad minim veniam, " + "quis nostrud exercitation ullamco\nlaboris nisi ut aliquip ex ea commodo consequat. " + "Duis aute irure dolor in reprehenderit in\nvoluptate velit esse cillum dolore eu fugiat " + "nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui " + "officia deserunt mollit anim id est laborum.\n\nLorem ipsum dolor sit amet, consectetur " + "adipiscing elit, sed do eiusmod tempor incididunt ut\nlabore et dolore magna aliqua. " + "Ut enim ad minim veniam, quis nostrud exercitation ullamco\nlaboris nisi ut aliquip " + "ex ea commodo consequat. Duis aute irure dolor in reprehenderit in\nvoluptate velit " + "esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non" + "\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\n" + ) + + def test_parse_document_non_pdf_uses_document_converter(): """Should route non-PDF content to DocumentConverter.""" parser = AdaptivePdfParser() @@ -308,3 +372,59 @@ def test_parse_document_non_pdf_uses_document_converter(): assert result == "docx content" mock_converter.return_value.convert_raw.assert_called_once() + + +EXPECTED_MD_FROM_ODT = ( + "# Document Title\n\n## Introduction\n\nThis is a normal paragraph with " + "**bold text**, \\\n_italic text_, and \\\n***bold italic text***." + "\\\n\n\nThis has ~~strikethrough~~ and \\\n`inline code`.\\\n\n\n" + "Visit [Example Site](https://example.com) for more info.\\\n\n\n" + "## Features\n\n - Fast parsing\n - Clean output\n - " + "Django integration\n - LLM\\-ready markdown\n\n" + "### Nested List\n\n - Parent item\n - Child A" + "\n - Child B\n - Another parent\n\n## Data Table\n\n" + "| Name | Age | City |\n|-------|-----|--------|\n" + "| Alice | 30 | Paris |\n| Bob | 25 | London |" + "\n\n\n## Conclusion\n\nThis document tests " + "the ODT to Markdown conversion pipeline.\n" +) + + +def test_parse_odt(sample_odt): + """Should extract odt document correctly.""" + parser = AdaptivePdfParser() + + result = parser.parse_document( + "sample.odt", "application/vnd.oasis.opendocument.text", sample_odt + ) + + assert result == EXPECTED_MD_FROM_ODT + + +def test_parse_document_odt_routed_correctly(sample_odt): + """Should route ODT content type to ODT parser.""" + parser = AdaptivePdfParser() + + with patch.object(parser, "parse_odt_document", return_value="odt content") as mock_parse: + result = parser.parse_document( + "sample.odt", "application/vnd.oasis.opendocument.text", sample_odt + ) + + assert result == "odt content" + mock_parse.assert_called_once_with(content=sample_odt) + + +def test_parse_odt_corrupt_input(): + """Should raise OdtParsingError on corrupt input.""" + parser = AdaptivePdfParser() + + with pytest.raises(OdtParsingError, match="Failed to parse ODT document"): + parser.parse_document("corrupt.odt", "application/vnd.oasis.opendocument.text", b"garbage") + + +def test_parse_odt_empty_input(): + """Should raise OdtParsingError on empty input.""" + parser = AdaptivePdfParser() + + with pytest.raises(OdtParsingError, match="Failed to parse ODT document"): + parser.parse_document("empty.odt", "application/vnd.oasis.opendocument.text", b"") diff --git a/src/backend/chat/tests/views/chat/conversations/test_conversation_with_document_upload.py b/src/backend/chat/tests/views/chat/conversations/test_conversation_with_document_upload.py index 868c4f6..3a42a74 100644 --- a/src/backend/chat/tests/views/chat/conversations/test_conversation_with_document_upload.py +++ b/src/backend/chat/tests/views/chat/conversations/test_conversation_with_document_upload.py @@ -6,6 +6,7 @@ import dataclasses import json import logging from io import BytesIO +from pathlib import Path from unittest import mock from django.contrib.sessions.backends.cache import SessionStore @@ -103,6 +104,13 @@ def fixture_sample_pdf_content(): return BytesIO(pdf_data) +@pytest.fixture(name="sample_odt_content") +def fixture_sample_odt_content(): + """Load a valid ODT file as BytesIO.""" + fixtures_dir = Path(__file__).parents[3] / "agent_rag" / "document_converter" / "fixtures" + return BytesIO((fixtures_dir / "sample.odt").read_bytes()) + + @pytest.fixture(name="mock_document_api") def fixture_mock_document_api(): """Fixture to mock the Albert API endpoints.""" @@ -186,6 +194,89 @@ def fixture_mock_document_api(): ) +@pytest.fixture(name="mock_odt_document_api") +def fixture_mock_odt_document_api(): + """Fixture to mock the Albert API endpoints.""" + # Mock collection creation + + document_name = "sample.odt" + document_content = "This is the content of the ODT." + prompt_tokens = 10 + completion_tokens = 20 + search_method = "semantic" + search_score = 0.9 + + responses.post( + "https://albert.api.etalab.gouv.fr/v1/collections", + json={"id": "123", "name": "test-collection"}, + status=status.HTTP_200_OK, + ) + + # Mock PDF parsing + responses.post( + "https://albert.api.etalab.gouv.fr/v1/parse-beta", + json={ + "data": [ + { + "content": "This is the content of the ODT.", + "metadata": {"document_name": "sample.odt"}, + } + ], + "usage": {"prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens}, + }, + status=status.HTTP_200_OK, + ) + + # Mock document upload + responses.post( + "https://albert.api.etalab.gouv.fr/v1/documents", + json={"id": 456}, + status=status.HTTP_201_CREATED, + ) + + # Mock document search + responses.post( + "https://albert.api.etalab.gouv.fr/v1/search", + json={ + "data": [ + { + "method": search_method, + "chunk": { + "id": 123, + "content": document_content, + "metadata": {"document_name": document_name}, + }, + "score": search_score, + } + ], + "usage": {"prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens}, + }, + status=status.HTTP_200_OK, + ) + + # Mock document indexing (Find API) + responses.post( + "https://find.api.example.com/api/v1.0/documents/index/", + json={"id": "456", "status": "indexed"}, + status=status.HTTP_200_OK, + ) + + # Mock document search (Find API) + responses.post( + "https://find.api.example.com/api/v1.0/documents/search/", + json=[ + { + "_source": { + "title.fr": document_name, + "content.fr": document_content, + }, + "_score": search_score, + } + ], + status=status.HTTP_200_OK, + ) + + @pytest.fixture(name="mock_summarization_agent") def fixture_mock_summarization_agent(): """Mock the SummarizationAgent to return a fixed summary.""" @@ -875,3 +966,279 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to }, "run_id": _run_id, } + + +@responses.activate +@respx.mock +@freeze_time() +def test_post_conversation_with_odt_document_upload( + # pylint: disable=too-many-arguments,too-many-positional-arguments + api_client, + mock_odt_document_api, # pylint: disable=unused-argument + sample_odt_content, + today_prompt_date, + mock_ai_agent_service, +): + """ + Test POST to /api/v1/chats/{pk}/conversation/ with an ODT document. + """ + chat_conversation = ChatConversationFactory(owner__language="en-us") + api_client.force_authenticate(user=chat_conversation.owner) + + odt_base64 = base64.b64encode(sample_odt_content.read()).decode("utf-8") + + message = UIMessage( + id="1", + role="user", + content="What does the document say?", + parts=[ + TextUIPart( + text="What does the document say?", + type="text", + ), + ], + experimental_attachments=[ + Attachment( + name="sample.odt", + contentType="application/vnd.oasis.opendocument.text", + url=f"data:application/vnd.oasis.opendocument.text;base64,{odt_base64}", + ) + ], + ) + + async def agent_model(messages: list[ModelMessage], _info: AgentInfo): + if len(messages) == 1: + yield { + 0: DeltaToolCall( + name="document_search_rag", + json_args='{"query": "What does the document say?"}', + ) + } + else: + yield "From the document, I can see that it says 'Hello ODT'." + + # Use the fixture with FunctionModel + with mock_ai_agent_service(FunctionModel(stream_function=agent_model)): + response = api_client.post( + f"/api/v1.0/chats/{chat_conversation.pk}/conversation/", + data={"messages": [message.model_dump(mode="json")]}, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK + assert response.get("Content-Type") == "text/event-stream" + assert response.get("x-vercel-ai-data-stream") == "v1" + assert response.streaming + + # Wait for the streaming content to be fully received + response_content = b"".join(response.streaming_content).decode("utf-8") + + # Replace UUIDs with placeholders for assertion + response_content = replace_uuids_with_placeholder(response_content) + + assert response_content == ( + '9:{"toolCallId":"XXX","toolName":"document_parsing",' + '"args":{"documents":[{"identifier":"sample.odt"}]}}\n' + 'a:{"toolCallId":"XXX","result":{"state":"done"}}\n' + 'b:{"toolCallId":"pyd_ai_YYY","toolName":"document_search_rag"}\n' + '9:{"toolCallId":"pyd_ai_YYY","toolName":"document_search_rag",' + '"args":{"query":"What does the document say?"}}\n' + 'h:{"sourceType":"url","id":"","url":"sample.odt","title":null,' + '"providerMetadata":{}}\n' + 'a:{"toolCallId":"pyd_ai_YYY","result":[{"url":"sample.odt","content":"This ' + 'is the content of the ODT.","score":0.9}]}\n' + "0:\"From the document, I can see that it says 'Hello ODT'.\"\n" + 'f:{"messageId":""}\n' + 'd:{"finishReason":"stop","usage":{"promptTokens":100,"completionTokens":20}}\n' + ) + + # Check that the conversation was updated + chat_conversation.refresh_from_db() + assert len(chat_conversation.messages) == 2 + + assert chat_conversation.messages[0].id == IsUUID(4) + assert chat_conversation.messages[0] == UIMessage( + id=chat_conversation.messages[0].id, + createdAt=timezone.now(), + content="What does the document say?", + reasoning=None, + experimental_attachments=None, + role="user", + annotations=None, + toolInvocations=None, + parts=[TextUIPart(type="text", text="What does the document say?")], + ) + + assert chat_conversation.messages[1].id == IsUUID(4) + assert chat_conversation.messages[1] == UIMessage( + id=chat_conversation.messages[1].id, + createdAt=timezone.now(), + content="From the document, I can see that it says 'Hello ODT'.", + reasoning=None, + experimental_attachments=None, + role="assistant", + annotations=None, + toolInvocations=None, + parts=[ + ToolInvocationUIPart( + type="tool-invocation", + toolInvocation=ToolInvocationCall( + toolCallId=chat_conversation.messages[1].parts[0].toolInvocation.toolCallId, + toolName="document_search_rag", + args={"query": "What does the document say?"}, + state="call", + step=None, + ), + ), + TextUIPart(type="text", text="From the document, I can see that it says 'Hello ODT'."), + SourceUIPart( + type="source", + source=LanguageModelV1Source( + sourceType="url", + id=chat_conversation.messages[1].parts[2].source.id, + url="sample.odt", + title=None, + providerMetadata={}, + ), + ), + ], + ) + + timezone_now = timezone.now().isoformat().replace("+00:00", "Z") + _formatted_date = formats.date_format(timezone.now(), "l d/m/Y", use_l10n=False) + + assert len(chat_conversation.pydantic_messages) == 4 + + _run_id = chat_conversation.pydantic_messages[0]["run_id"] + + assert chat_conversation.pydantic_messages[0] == { + "instructions": "You are a helpful test assistant :)\n\n" + f"{today_prompt_date}\n\n" + "Answer in english.\n\n" + "Use document_search_rag ONLY to retrieve specific passages from " + "attached documents. Do NOT use it to summarize; for summaries, " + "call the summarize tool instead.\n\nWhen you receive a result from the " + "summarization tool, you MUST return it directly to the user without " + "any modification, paraphrasing, or additional summarization." + "The tool already produces optimized summaries that should be " + "presented verbatim.You may translate the summary if required, " + "but you MUST preserve all the information from the original summary." + "You may add a follow-up question after the summary if needed.\n\n" + "[Internal context] User documents are attached to this conversation. " + "Do not request re-upload of documents; consider them already " + "available via the internal store.", + "kind": "request", + "metadata": None, + "parts": [ + { + "content": ["What does the document say?"], + "part_kind": "user-prompt", + "timestamp": timezone_now, + }, + ], + "run_id": _run_id, + "timestamp": timezone_now, + } + assert chat_conversation.pydantic_messages[1] == { + "finish_reason": None, + "kind": "response", + "metadata": None, + "model_name": "function::agent_model", + "parts": [ + { + "args": '{"query": "What does the document say?"}', + "id": None, + "part_kind": "tool-call", + "tool_call_id": chat_conversation.pydantic_messages[1]["parts"][0]["tool_call_id"], + "tool_name": "document_search_rag", + "provider_details": None, + "provider_name": None, + } + ], + "provider_details": None, + "provider_name": None, + "provider_response_id": None, + "provider_url": None, + "timestamp": timezone_now, + "usage": { + "cache_audio_read_tokens": 0, + "cache_read_tokens": 0, + "cache_write_tokens": 0, + "details": {}, + "input_audio_tokens": 0, + "input_tokens": 50, + "output_audio_tokens": 0, + "output_tokens": 8, + }, + "run_id": _run_id, + } + assert chat_conversation.pydantic_messages[2] == { + "instructions": ( + "You are a helpful test assistant :)\n\n" + f"{today_prompt_date}\n\n" + "Answer in english.\n\n" + "Use document_search_rag ONLY to retrieve specific passages from " + "attached documents. Do NOT use it to summarize; for summaries, " + "call the summarize tool instead.\n\nWhen you receive a result from the " + "summarization tool, you MUST return it directly to the user without " + "any modification, paraphrasing, or additional summarization." + "The tool already produces optimized summaries that should be " + "presented verbatim.You may translate the summary if required, " + "but you MUST preserve all the information from the original summary." + "You may add a follow-up question after the summary if needed.\n\n" + "[Internal context] User documents are attached to this conversation. " + "Do not request re-upload of documents; consider them already " + "available via the internal store." + ), + "kind": "request", + "metadata": None, + "parts": [ + { + "content": [ + { + "content": "This is the content of the ODT.", + "score": 0.9, + "url": "sample.odt", + } + ], + "metadata": {"sources": ["sample.odt"]}, + "part_kind": "tool-return", + "timestamp": timezone_now, + "tool_call_id": chat_conversation.pydantic_messages[2]["parts"][0]["tool_call_id"], + "tool_name": "document_search_rag", + } + ], + "run_id": _run_id, + "timestamp": timezone_now, + } + assert chat_conversation.pydantic_messages[3] == { + "finish_reason": None, + "kind": "response", + "metadata": None, + "model_name": "function::agent_model", + "parts": [ + { + "content": "From the document, I can see that it says 'Hello ODT'.", + "id": None, + "part_kind": "text", + "provider_details": None, + "provider_name": None, + } + ], + "provider_details": None, + "provider_name": None, + "provider_response_id": None, + "provider_url": None, + "timestamp": timezone_now, + "usage": { + "cache_audio_read_tokens": 0, + "cache_read_tokens": 0, + "cache_write_tokens": 0, + "details": {}, + "input_audio_tokens": 0, + "input_tokens": 50, + "output_audio_tokens": 0, + "output_tokens": 12, + }, + "run_id": _run_id, + } diff --git a/src/backend/conversations/settings.py b/src/backend/conversations/settings.py index a8ed73d..b11b8a5 100755 --- a/src/backend/conversations/settings.py +++ b/src/backend/conversations/settings.py @@ -733,6 +733,7 @@ class Base(BraveSettings, Configuration): "image/png", "image/gif", "image/webp", + "application/vnd.oasis.opendocument.text", ], environ_name="RAG_FILES_ACCEPTED_FORMATS", environ_prefix=None, diff --git a/src/backend/core/tests/file_upload/test_generate_temporary_url.py b/src/backend/core/tests/file_upload/test_generate_temporary_url.py index e92cfad..30a5f1d 100644 --- a/src/backend/core/tests/file_upload/test_generate_temporary_url.py +++ b/src/backend/core/tests/file_upload/test_generate_temporary_url.py @@ -96,6 +96,7 @@ def test_generate_temporary_url_various_key_formats(): "conversation-123/attachments/file-uuid.pdf", "nested/folder/structure/file.jpg", "file_with_special-chars_123.png", + "my-document.odt", ] urls = [] diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 804f426..f0288e7 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -68,6 +68,7 @@ dependencies = [ "uvicorn==0.38.0", "whitenoise==6.11.0", "pypdf==6.9.1", + "odfdo==3.22.1", ] [project.urls] diff --git a/src/backend/uv.lock b/src/backend/uv.lock index 0fb4013..94334cf 100644 --- a/src/backend/uv.lock +++ b/src/backend/uv.lock @@ -433,6 +433,7 @@ dependencies = [ { name = "markitdown" }, { name = "mozilla-django-oidc" }, { name = "nested-multipart-parser" }, + { name = "odfdo" }, { name = "posthog" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, @@ -512,6 +513,7 @@ requires-dist = [ { name = "markitdown", specifier = "==0.0.2" }, { name = "mozilla-django-oidc", specifier = "==4.0.1" }, { name = "nested-multipart-parser", specifier = "==1.6.0" }, + { name = "odfdo", specifier = "==3.22.1" }, { name = "posthog", specifier = "==7.0.0" }, { name = "psycopg", extras = ["binary"], specifier = "==3.2.12" }, { name = "pydantic", specifier = "==2.12.4" }, @@ -1736,6 +1738,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6c/f8/fa85b2eac68ec631d0b631abc448552cb17d39afd17ec53dcbcc3537681a/numpy-2.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a7870e8c5fc11aef57d6fea4b4085e537a3a60ad2cdd14322ed531fdca68d261", size = 10382981, upload-time = "2026-01-10T06:43:52.575Z" }, ] +[[package]] +name = "odfdo" +version = "3.22.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/52/f4e93d451fe24fc8a785b29588d1a5e29dfe4bd367daa922249e5e123e57/odfdo-3.22.1.tar.gz", hash = "sha256:3d66b49dd95ca2f85964d928014851f1e3b78aae23cf1e6b2cfb07781cb696f0", size = 297671, upload-time = "2026-03-22T11:10:06.782Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/12/c8fd5bd73c2214dc9d6db5034bace04b38b42cbb43f7e56696849a7f7aa2/odfdo-3.22.1-py3-none-any.whl", hash = "sha256:4463bb7e330968b7891b6a45ddad89f03a3f5ec00bfc9fd69346041c192575e7", size = 405632, upload-time = "2026-03-22T11:10:05.143Z" }, +] + [[package]] name = "olefile" version = "0.47" diff --git a/src/frontend/apps/e2e/__tests__/app-conversations/common.ts b/src/frontend/apps/e2e/__tests__/app-conversations/common.ts index 69dc0ab..80ec9e9 100644 --- a/src/frontend/apps/e2e/__tests__/app-conversations/common.ts +++ b/src/frontend/apps/e2e/__tests__/app-conversations/common.ts @@ -43,7 +43,8 @@ export const CONFIG = { 'image/jpeg,' + 'image/png,' + 'image/gif,' + - 'image/webp', + 'image/webp,' + + 'application/vnd.oasis.opendocument.text', } as const; export const overrideConfig = async (