fix(ocr): rewrite MistralOCRProvider to use native Mistral OCR API

- PDFs are now uploaded to Mistral Files API (POST /v1/files) and
  processed via a signed document_url, resolving the 422 error caused
  by passing data:application/pdf;base64,... to an image endpoint
- Images (JPEG/PNG/GIF/WEBP/BMP/TIFF) use base64 image_url directly
- Unsupported MIME types raise a clear ValueError
- Magic-byte fallback detects PDFs with no file extension
- Switches from openai chat completions to requests HTTP calls
- Adds helper method _upload_pdf_and_get_document()
- Adds TestMistralOCRProvider with 9 unit tests covering all paths

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-02-24 12:13:38 +00:00
parent 2703221b9c
commit c7423834d5
2 changed files with 357 additions and 27 deletions
+243
View File
@@ -777,3 +777,246 @@ startxref
# Verify rotation was applied despite string keys
assert result["status"] == "rotated"
assert "applied_rotations" in result
@pytest.mark.unit
class TestMistralOCRProvider:
"""Tests for MistralOCRProvider Mistral native OCR API integration."""
def _make_provider(self):
from app.utils.ocr_provider import MistralOCRProvider
return MistralOCRProvider()
# ------------------------------------------------------------------
# Helpers: build mock response objects
# ------------------------------------------------------------------
def _mock_response(self, json_data: dict, status_code: int = 200):
mock_resp = Mock()
mock_resp.status_code = status_code
mock_resp.json.return_value = json_data
mock_resp.raise_for_status = Mock()
return mock_resp
def _mock_error_response(self, status_code: int = 422):
from requests import HTTPError
mock_resp = Mock()
mock_resp.status_code = status_code
mock_resp.raise_for_status.side_effect = HTTPError(response=mock_resp)
return mock_resp
# ------------------------------------------------------------------
# Missing API key
# ------------------------------------------------------------------
def test_missing_api_key_raises(self, tmp_path):
"""ValueError is raised when MISTRAL_API_KEY is not configured."""
provider = self._make_provider()
dummy_pdf = tmp_path / "doc.pdf"
dummy_pdf.write_bytes(b"%PDF-1.4\n%%EOF")
with patch("app.utils.ocr_provider.settings") as mock_settings:
mock_settings.mistral_api_key = None
mock_settings.mistral_ocr_model = None
with pytest.raises(ValueError, match="MISTRAL_API_KEY must be set"):
provider.process(str(dummy_pdf))
# ------------------------------------------------------------------
# PDF workflow
# ------------------------------------------------------------------
def test_pdf_uses_document_url_workflow(self, tmp_path):
"""PDF files are uploaded then processed via document_url."""
provider = self._make_provider()
pdf_file = tmp_path / "sample.pdf"
pdf_file.write_bytes(b"%PDF-1.4\n%%EOF")
upload_resp = self._mock_response({"id": "file-abc123"})
signed_url_resp = self._mock_response({"url": "https://signed.example.com/doc"})
ocr_resp = self._mock_response({"pages": [{"markdown": "Hello PDF World"}]})
with (
patch("app.utils.ocr_provider.settings") as mock_settings,
patch("requests.post", side_effect=[upload_resp, ocr_resp]) as mock_post,
patch("requests.get", return_value=signed_url_resp) as mock_get,
):
mock_settings.mistral_api_key = "test-key"
mock_settings.mistral_ocr_model = "mistral-ocr-latest"
result = provider.process(str(pdf_file))
assert result.provider == "mistral"
assert result.text == "Hello PDF World"
# First POST should be the file upload
upload_call = mock_post.call_args_list[0]
assert "/files" in upload_call[0][0]
assert upload_call[1]["data"] == {"purpose": "ocr"}
# GET should fetch the signed URL
get_call = mock_get.call_args_list[0]
assert "file-abc123" in get_call[0][0]
# Second POST should be the OCR call
ocr_call = mock_post.call_args_list[1]
assert "/ocr" in ocr_call[0][0]
ocr_json = ocr_call[1]["json"]
assert ocr_json["document"]["type"] == "document_url"
assert ocr_json["document"]["document_url"] == "https://signed.example.com/doc"
def test_pdf_multi_page_text_joined(self, tmp_path):
"""Text from multiple pages is joined with double newlines."""
provider = self._make_provider()
pdf_file = tmp_path / "multi.pdf"
pdf_file.write_bytes(b"%PDF-1.4\n%%EOF")
upload_resp = self._mock_response({"id": "file-xyz"})
signed_url_resp = self._mock_response({"url": "https://signed.example.com/multi"})
ocr_resp = self._mock_response({"pages": [{"markdown": "Page one text"}, {"markdown": "Page two text"}]})
with (
patch("app.utils.ocr_provider.settings") as mock_settings,
patch("requests.post", side_effect=[upload_resp, ocr_resp]),
patch("requests.get", return_value=signed_url_resp),
):
mock_settings.mistral_api_key = "test-key"
mock_settings.mistral_ocr_model = "mistral-ocr-latest"
result = provider.process(str(pdf_file))
assert "Page one text" in result.text
assert "Page two text" in result.text
# ------------------------------------------------------------------
# Image workflow
# ------------------------------------------------------------------
def test_image_uses_image_url_workflow(self, tmp_path):
"""Image files are base64-encoded and passed as image_url (not document_url)."""
provider = self._make_provider()
img_file = tmp_path / "photo.jpg"
img_file.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 20) # minimal JPEG bytes
ocr_resp = self._mock_response({"pages": [{"markdown": "Image text here"}]})
with (
patch("app.utils.ocr_provider.settings") as mock_settings,
patch("requests.post", return_value=ocr_resp) as mock_post,
patch("requests.get") as mock_get,
):
mock_settings.mistral_api_key = "test-key"
mock_settings.mistral_ocr_model = "mistral-ocr-latest"
result = provider.process(str(img_file))
assert result.provider == "mistral"
assert result.text == "Image text here"
# Should only make a single POST (no file upload step)
assert mock_post.call_count == 1
assert mock_get.call_count == 0
ocr_call = mock_post.call_args_list[0]
assert "/ocr" in ocr_call[0][0]
ocr_json = ocr_call[1]["json"]
assert ocr_json["document"]["type"] == "image_url"
assert ocr_json["document"]["image_url"].startswith("data:image/jpeg;base64,")
# ------------------------------------------------------------------
# Unsupported file type
# ------------------------------------------------------------------
def test_unsupported_mime_type_raises(self, tmp_path):
"""ValueError is raised for unsupported file types."""
provider = self._make_provider()
txt_file = tmp_path / "document.txt"
txt_file.write_text("hello world")
with patch("app.utils.ocr_provider.settings") as mock_settings:
mock_settings.mistral_api_key = "test-key"
mock_settings.mistral_ocr_model = "mistral-ocr-latest"
with pytest.raises(ValueError, match="Unsupported file type"):
provider.process(str(txt_file))
def test_unknown_extension_pdf_magic_bytes_detected(self, tmp_path):
"""Files without extension are identified as PDF via magic bytes."""
provider = self._make_provider()
no_ext_file = tmp_path / "nodotfile"
no_ext_file.write_bytes(b"%PDF-1.4\n%%EOF")
upload_resp = self._mock_response({"id": "file-magic"})
signed_url_resp = self._mock_response({"url": "https://signed.example.com/magic"})
ocr_resp = self._mock_response({"pages": [{"markdown": "Magic PDF"}]})
with (
patch("app.utils.ocr_provider.settings") as mock_settings,
patch("requests.post", side_effect=[upload_resp, ocr_resp]),
patch("requests.get", return_value=signed_url_resp),
):
mock_settings.mistral_api_key = "test-key"
mock_settings.mistral_ocr_model = "mistral-ocr-latest"
result = provider.process(str(no_ext_file))
assert result.text == "Magic PDF"
def test_unknown_extension_non_pdf_magic_bytes_raises(self, tmp_path):
"""Files without extension that aren't PDFs raise ValueError."""
provider = self._make_provider()
unknown_file = tmp_path / "unknownfile"
unknown_file.write_bytes(b"\x00\x01\x02\x03\x04")
with patch("app.utils.ocr_provider.settings") as mock_settings:
mock_settings.mistral_api_key = "test-key"
mock_settings.mistral_ocr_model = "mistral-ocr-latest"
with pytest.raises(ValueError, match="Cannot determine file type"):
provider.process(str(unknown_file))
# ------------------------------------------------------------------
# Upload / API error propagation
# ------------------------------------------------------------------
def test_upload_failure_raises_http_error(self, tmp_path):
"""HTTPError from the Files API upload is propagated to the caller."""
from requests import HTTPError
provider = self._make_provider()
pdf_file = tmp_path / "bad.pdf"
pdf_file.write_bytes(b"%PDF-1.4\n%%EOF")
with (
patch("app.utils.ocr_provider.settings") as mock_settings,
patch("requests.post", return_value=self._mock_error_response(status_code=401)),
):
mock_settings.mistral_api_key = "bad-key"
mock_settings.mistral_ocr_model = "mistral-ocr-latest"
with pytest.raises(HTTPError):
provider.process(str(pdf_file))
def test_ocr_api_failure_raises_http_error(self, tmp_path):
"""HTTPError from the /ocr endpoint is propagated to the caller."""
from requests import HTTPError
provider = self._make_provider()
pdf_file = tmp_path / "bad.pdf"
pdf_file.write_bytes(b"%PDF-1.4\n%%EOF")
upload_resp = self._mock_response({"id": "file-err"})
signed_url_resp = self._mock_response({"url": "https://signed.example.com/err"})
with (
patch("app.utils.ocr_provider.settings") as mock_settings,
patch("requests.post", side_effect=[upload_resp, self._mock_error_response(status_code=422)]),
patch("requests.get", return_value=signed_url_resp),
):
mock_settings.mistral_api_key = "test-key"
mock_settings.mistral_ocr_model = "mistral-ocr-latest"
with pytest.raises(HTTPError):
provider.process(str(pdf_file))