From bc28e05bb7b3ad043d336a3a0ae8f6e5b51a150e Mon Sep 17 00:00:00 2001 From: Laurent Paoletti Date: Mon, 9 Feb 2026 15:04:27 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(back)=20add=20ODT=20parsing=20support?= =?UTF-8?q?=20and=20improve=20document=20routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add odfdo dependency for ODT-to-markdown conversion - Refactor BaseParser to route by content type (PDF, ODT, other) - Extract OdtParserMixin and AdaptivePdfParserMixin for composability - Add OdtParsingError for corrupt/empty ODT files - Accept ODT in RAG upload formats, add pandoc to Docker image - Add tests for PDF/ODT routing, adaptive method selection, and ODT errors Signed-off-by: Laurent Paoletti --- CHANGELOG.md | 1 + .../document_converter/markitdown.py | 3 +- .../chat/agent_rag/document_converter/odt.py | 31 ++ .../agent_rag/document_converter/parser.py | 79 ++-- .../document_converter/fixtures/sample.odt | Bin 0 -> 9913 bytes .../test_adaptive_pdf_parser.py | 120 ++++++ .../test_conversation_with_document_upload.py | 367 ++++++++++++++++++ src/backend/conversations/settings.py | 1 + .../test_generate_temporary_url.py | 1 + src/backend/pyproject.toml | 1 + src/backend/uv.lock | 14 + .../e2e/__tests__/app-conversations/common.ts | 3 +- 12 files changed, 578 insertions(+), 43 deletions(-) create mode 100644 src/backend/chat/agent_rag/document_converter/odt.py create mode 100644 src/backend/chat/tests/agent_rag/document_converter/fixtures/sample.odt 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 0000000000000000000000000000000000000000..fa124b0981545ace12aa7b351f6947b2bf0bedfa GIT binary patch literal 9913 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 literal 0 HcmV?d00001 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 (