✨(back) add ODT parsing support and improve document routing
- 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 <lp@providenz.fr>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Binary file not shown.
@@ -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"")
|
||||
|
||||
+367
@@ -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":"<mocked_uuid>","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":"<mocked_uuid>"}\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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -68,6 +68,7 @@ dependencies = [
|
||||
"uvicorn==0.38.0",
|
||||
"whitenoise==6.11.0",
|
||||
"pypdf==6.9.1",
|
||||
"odfdo==3.22.1",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
Generated
+14
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user