Skip to content

Commit 0b9fdbf

Browse files
onyx-cherry-pick[bot]Danelegendclaude
authored
fix(user-files): skip embedded-image caps when image extraction is off (#14251) to release v4.6 (#14257)
Co-authored-by: Danelegend <43459662+Danelegend@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 846813c commit 0b9fdbf

4 files changed

Lines changed: 138 additions & 15 deletions

File tree

backend/onyx/file_processing/extract_file_text.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -896,22 +896,26 @@ def _extract_text_and_images(
896896
# Default processing
897897
try:
898898
extension = get_file_ext(file_name)
899-
# docx example for embedded images
899+
# One setting read per file. With extraction off, no image is decoded
900+
# for any format, which is what lets upload validation skip its caps.
901+
extract_images = get_image_extraction_and_analysis_enabled()
902+
900903
if extension == ".docx":
901904
text_content, images = read_docx_file(
902-
file, file_name, extract_images=True, image_callback=image_callback
905+
file,
906+
file_name,
907+
extract_images=extract_images,
908+
image_callback=image_callback,
903909
)
904910
return ExtractionResult(
905911
text_content=text_content, embedded_images=images, metadata={}
906912
)
907913

908-
# PDF example: we do not show complicated PDF image extraction here
909-
# so we simply extract text for now and skip images.
910914
if extension == ".pdf":
911915
text_content, pdf_metadata, images = read_pdf_file(
912916
file,
913917
pdf_pass,
914-
extract_images=get_image_extraction_and_analysis_enabled(),
918+
extract_images=extract_images,
915919
image_callback=image_callback,
916920
)
917921
return ExtractionResult(
@@ -920,7 +924,10 @@ def _extract_text_and_images(
920924

921925
if extension == ".pptx":
922926
text_content, images = read_pptx_file(
923-
file, file_name, extract_images=True, image_callback=image_callback
927+
file,
928+
file_name,
929+
extract_images=extract_images,
930+
image_callback=image_callback,
924931
)
925932
return ExtractionResult(
926933
text_content=text_content, embedded_images=images, metadata={}

backend/onyx/server/features/projects/projects_file_utils.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -269,10 +269,12 @@ def categorize_uploaded_files(
269269
# images (either per-file or accumulated across this upload
270270
# batch). A file with thousands of embedded images can OOM the
271271
# user-file-processing celery worker because every image is
272-
# decoded with PIL and then sent to the vision LLM.
272+
# decoded with PIL and then sent to the vision LLM. With image
273+
# extraction off, no image is ever decoded, so the caps (and
274+
# the cost of counting) do not apply.
273275
count: int = 0
274276
image_bearing_ext = extension in (".pdf", ".docx")
275-
if image_bearing_ext:
277+
if image_bearing_ext and image_extraction_enabled:
276278
file_cap = MAX_EMBEDDED_IMAGES_PER_FILE
277279
batch_cap = MAX_EMBEDDED_IMAGES_PER_UPLOAD
278280
# Use the larger of the two caps as the short-circuit
@@ -320,9 +322,9 @@ def categorize_uploaded_files(
320322
if not text_content:
321323
# Documents with embedded images (e.g. scans) have no
322324
# extractable text but can still be indexed via the
323-
# vision-LLM captioning path when image analysis is
324-
# enabled.
325-
if image_bearing_ext and count > 0 and image_extraction_enabled:
325+
# vision-LLM captioning path. `count` is only populated
326+
# when image extraction is enabled.
327+
if image_bearing_ext and count > 0:
326328
results.acceptable.append(upload)
327329
results.acceptable_file_to_token_count[filename] = 0
328330
try:
@@ -336,11 +338,15 @@ def categorize_uploaded_files(
336338
continue
337339

338340
logger.warning("No text content extracted from '%s'", filename)
339-
results.rejected.append(
340-
RejectedFile(
341-
filename=filename,
342-
reason=f"Unsupported file type: {extension}",
341+
if image_bearing_ext and not image_extraction_enabled:
342+
reason = (
343+
"No text could be extracted, and image processing "
344+
"is disabled by an admin"
343345
)
346+
else:
347+
reason = f"Unsupported file type: {extension}"
348+
results.rejected.append(
349+
RejectedFile(filename=filename, reason=reason)
344350
)
345351
continue
346352

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""The workspace image-extraction setting gates embedded-image extraction for every
2+
image-bearing format, not only PDFs."""
3+
4+
import io
5+
from typing import Any
6+
from unittest.mock import patch
7+
8+
import pytest
9+
10+
from onyx.file_processing.extract_file_text import extract_text_and_images
11+
12+
_MOD = "onyx.file_processing.extract_file_text"
13+
14+
15+
@pytest.mark.parametrize("enabled", [True, False])
16+
@pytest.mark.parametrize(
17+
"extension, reader, reader_return",
18+
[
19+
(".docx", "read_docx_file", ("text", [])),
20+
(".pdf", "read_pdf_file", ("text", {}, [])),
21+
(".pptx", "read_pptx_file", ("text", [])),
22+
],
23+
)
24+
def test_extract_images_follows_workspace_setting(
25+
enabled: bool,
26+
extension: str,
27+
reader: str,
28+
reader_return: tuple[Any, ...],
29+
) -> None:
30+
with (
31+
patch(f"{_MOD}.get_unstructured_api_key", return_value=None),
32+
patch(
33+
f"{_MOD}.get_image_extraction_and_analysis_enabled",
34+
return_value=enabled,
35+
),
36+
patch(f"{_MOD}.{reader}", return_value=reader_return) as mock_reader,
37+
):
38+
result = extract_text_and_images(io.BytesIO(b"bytes"), f"doc{extension}")
39+
40+
assert result.text_content == "text"
41+
mock_reader.assert_called_once()
42+
assert mock_reader.call_args.kwargs["extract_images"] is enabled

backend/tests/unit/onyx/server/test_projects_file_utils.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,3 +478,71 @@ def test_pdf_over_token_threshold_rejected(
478478
assert result.rejected[0].filename == "big.pdf"
479479
assert "1K token limit" in result.rejected[0].reason
480480
assert len(result.acceptable) == 0
481+
482+
483+
_IMAGE_BEARING_CASES = [
484+
("many_images.pdf", "count_pdf_embedded_images"),
485+
("many_images.docx", "count_docx_embedded_images"),
486+
]
487+
488+
489+
@pytest.mark.parametrize("filename, counter_name", _IMAGE_BEARING_CASES)
490+
def test_many_images_accepted_when_image_extraction_disabled(
491+
monkeypatch: pytest.MonkeyPatch, filename: str, counter_name: str
492+
) -> None:
493+
"""With image extraction off, no image is ever decoded, so the embedded-image
494+
caps do not apply and the (expensive) count is skipped."""
495+
_patch_common_dependencies(monkeypatch, upload_size_mb=1000)
496+
monkeypatch.setattr(
497+
utils, "get_image_extraction_and_analysis_enabled", lambda: False
498+
)
499+
counter = MagicMock(return_value=utils.MAX_EMBEDDED_IMAGES_PER_FILE + 1)
500+
monkeypatch.setattr(utils, counter_name, counter)
501+
monkeypatch.setattr(utils, "extract_file_text", lambda **_kwargs: "some text")
502+
503+
upload = _make_upload(filename, size=100)
504+
result = utils.categorize_uploaded_files([upload], MagicMock())
505+
506+
assert result.rejected == []
507+
assert result.acceptable == [upload]
508+
counter.assert_not_called()
509+
510+
511+
@pytest.mark.parametrize("filename, counter_name", _IMAGE_BEARING_CASES)
512+
def test_many_images_rejected_when_image_extraction_enabled(
513+
monkeypatch: pytest.MonkeyPatch, filename: str, counter_name: str
514+
) -> None:
515+
_patch_common_dependencies(monkeypatch, upload_size_mb=1000)
516+
monkeypatch.setattr(
517+
utils, "get_image_extraction_and_analysis_enabled", lambda: True
518+
)
519+
counter = MagicMock(return_value=utils.MAX_EMBEDDED_IMAGES_PER_FILE + 1)
520+
monkeypatch.setattr(utils, counter_name, counter)
521+
monkeypatch.setattr(utils, "extract_file_text", lambda **_kwargs: "some text")
522+
523+
upload = _make_upload(filename, size=100)
524+
result = utils.categorize_uploaded_files([upload], MagicMock())
525+
526+
counter.assert_called_once()
527+
assert result.acceptable == []
528+
assert len(result.rejected) == 1
529+
assert "too many embedded images" in result.rejected[0].reason
530+
531+
532+
@pytest.mark.parametrize("filename", ["scan.pdf", "scan.docx"])
533+
def test_no_text_rejected_with_image_processing_disabled_reason(
534+
monkeypatch: pytest.MonkeyPatch, filename: str
535+
) -> None:
536+
"""A scan-only document cannot be indexed when captioning is off; say why."""
537+
_patch_common_dependencies(monkeypatch, upload_size_mb=1000)
538+
monkeypatch.setattr(
539+
utils, "get_image_extraction_and_analysis_enabled", lambda: False
540+
)
541+
monkeypatch.setattr(utils, "extract_file_text", lambda **_kwargs: "")
542+
543+
upload = _make_upload(filename, size=100)
544+
result = utils.categorize_uploaded_files([upload], MagicMock())
545+
546+
assert result.acceptable == []
547+
assert len(result.rejected) == 1
548+
assert "image processing is disabled" in result.rejected[0].reason

0 commit comments

Comments
 (0)