Compare commits

...

1 Commits

Author SHA1 Message Date
Laurent Paoletti bc28e05bb7 (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>
2026-03-25 16:11:23 +01:00
12 changed files with 578 additions and 43 deletions
+1
View File
@@ -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
)
@@ -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"")
@@ -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,
}
+1
View File
@@ -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 = []
+1
View File
@@ -68,6 +68,7 @@ dependencies = [
"uvicorn==0.38.0",
"whitenoise==6.11.0",
"pypdf==6.9.1",
"odfdo==3.22.1",
]
[project.urls]
+14
View File
@@ -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 (