fix(indexer): понятная ошибка, если загруженный "PDF" на самом деле не PDF
All checks were successful
Deploy / test (push) Successful in 2m41s
Deploy / deploy (push) Successful in 11s

Живой репорт: проверка плагиата упала с "Не удалось открыть PDF: code=7: no
objects found" — сырая внутренняя ошибка MuPDF. Разобрал файл из MinIO: это
HTML-страница (веб-интерфейс роутера D-Link DAP-400P), сохранённая с
расширением .pdf — 2049 байт, не PDF вообще. Система корректно отказалась
парсить мусор (это не баг пайплайна — реальный битый/не-PDF файл юзера), но
сообщение об ошибке было нечитаемым.

extract_text_from_pdf теперь проверяет magic-байты (%PDF-) ДО попытки
fitz.open() и даёт понятное "Файл повреждён или не является PDF-документом"
для этого частого случая; для настоящих PDF с внутренней порчей — прежнее
поведение (сырая ошибка MuPDF как detail, для диагностики).

4 юнит-теста (валидный PDF, HTML под видом PDF, пустые байты, битый заголовок
PDF). Тестов всего: 122 (было 118).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-08-24 18:55:24 +05:00
parent 712dc383ea
commit df2dfcc456
4 changed files with 50 additions and 1 deletions

View File

@@ -208,7 +208,7 @@ docker compose -f docker-compose.prod.yml --profile observability up -d promethe
1. **Линт** — `ruff` (весь Python) + `mypy` (чистая доменная логика). 1. **Линт** — `ruff` (весь Python) + `mypy` (чистая доменная логика).
Конфиги: [`ruff.toml`](ruff.toml), [`mypy.ini`](mypy.ini). Конфиги: [`ruff.toml`](ruff.toml), [`mypy.ini`](mypy.ini).
2. **Юнит-тесты** — `pytest` по сервисам: 118 тестов на ядро детекции, скоринга, 2. **Юнит-тесты** — `pytest` по сервисам: 122 теста на ядро детекции, скоринга,
парсеров, форматирования и OAuth, без внешней инфры (БД/Redis/GPU/Ollama парсеров, форматирования и OAuth, без внешней инфры (БД/Redis/GPU/Ollama
замоканы либо не нужны). замоканы либо не нужны).
@@ -231,6 +231,7 @@ make test-one SVC=worker-gost # тесты одного сервиса
| L2 — нечёткие (MinHash LSH) | `worker-indexer/app/algorithms/minhash.py` | 6 | | L2 — нечёткие (MinHash LSH) | `worker-indexer/app/algorithms/minhash.py` | 6 |
| Разбиение на фрагменты | `worker-indexer/app/fragments.py` | 5 | | Разбиение на фрагменты | `worker-indexer/app/fragments.py` | 5 |
| Автопополнение корпуса | `worker-indexer/app/staging.py` | 6 | | Автопополнение корпуса | `worker-indexer/app/staging.py` | 6 |
| Извлечение текста из PDF | `worker-indexer/app/extractors/pdf.py` | 4 |
| L3 — семантический индекс (FAISS) | `worker-gpu/app/faiss_manager.py` | 5 | | L3 — семантический индекс (FAISS) | `worker-gpu/app/faiss_manager.py` | 5 |
| L3 — векторный бэкенд (Qdrant + выбор) | `worker-gpu/app/qdrant_manager.py`, `vector_store.py` | 9 | | L3 — векторный бэкенд (Qdrant + выбор) | `worker-gpu/app/qdrant_manager.py`, `vector_store.py` | 9 |
| L4 — LLM-парафраз | `worker-gpu/app/ollama_client.py` | 12 | | L4 — LLM-парафраз | `worker-gpu/app/ollama_client.py` | 12 |

View File

@@ -23,6 +23,12 @@ def extract_text_from_pdf(data: bytes) -> str:
""" """
import fitz # PyMuPDF import fitz # PyMuPDF
# Частый случай: файл с расширением .pdf, но внутри — HTML/другое (например,
# сохранённая веб-страница). Даём понятное сообщение, а не сырую ошибку MuPDF
# вида "code=7: no objects found", которая ничего не скажет пользователю.
if not data.startswith(b"%PDF-"):
raise ValueError("Файл повреждён или не является PDF-документом")
try: try:
doc = fitz.open(stream=data, filetype="pdf") doc = fitz.open(stream=data, filetype="pdf")
except Exception as e: except Exception as e:

View File

@@ -6,3 +6,4 @@ xxhash==3.4.1
datasketch==1.6.5 datasketch==1.6.5
numpy==1.26.4 numpy==1.26.4
pydantic-settings==2.2.1 pydantic-settings==2.2.1
PyMuPDF==1.24.0

View File

@@ -0,0 +1,41 @@
"""Юнит-тесты извлечения текста из PDF (app.extractors.pdf)."""
import pytest
from app.extractors.pdf import extract_text_from_pdf
def _minimal_pdf_bytes(text: str) -> bytes:
"""Собрать валидный однострочный PDF с заданным текстом через сам PyMuPDF."""
import fitz
doc = fitz.open()
page = doc.new_page()
page.insert_text((72, 72), text)
data = doc.tobytes()
doc.close()
return data
def test_extracts_text_from_valid_pdf():
# ASCII — встроенный шрифт PyMuPDF не рендерит кириллицу без явного указания
# шрифта с поддержкой юникода; для проверки самой логики извлечения не важно.
text = extract_text_from_pdf(_minimal_pdf_bytes("Hello, world"))
assert "Hello, world" in text
def test_non_pdf_content_raises_friendly_error():
# Частый реальный случай: файл с расширением .pdf, а внутри — HTML
html = b"<!DOCTYPE html><html><body>not a pdf</body></html>"
with pytest.raises(ValueError, match="не является PDF"):
extract_text_from_pdf(html)
def test_empty_bytes_raises_friendly_error():
with pytest.raises(ValueError, match="не является PDF"):
extract_text_from_pdf(b"")
def test_truncated_pdf_header_only_raises():
# Начинается как PDF (проходит magic-byte проверку), но структуры внутри нет
with pytest.raises(ValueError):
extract_text_from_pdf(b"%PDF-1.4\n" + b"garbage" * 20)