Перейти к содержанию

Загрузка и анализ файлов

Шаг 1

В предыдущих разделах пятого модуля мы начали превращать наш проект в полноценный AI-бэкенд и постепенно перенесли его возможности в HTTP API. Теперь через FastAPI-приложение можно анализировать обращения, управлять чат-сессиями и получать потоковые ответы модели в виде SSE-событий.

Теперь подключим к FastAPI ещё одну большую часть проекта, а именно анализ файлов. И пользователь сможет выбрать документ на своём компьютере, отправить его вместе с вопросом и получить типизированный JSON-ответ с кратким содержанием, найденными фактами и ссылками на части исходного файла.

При этом заново реализовывать анализ файлов не потребуется. Ещё в четвёртом модуле мы создали DocumentAIService, который уже выполняет всю необходимую обработку документа. Он проверяет файл, извлекает из него данные, готовит подходящий контекст, обращается к модели и проверяет структурированный результат. То есть в этом разделе нам нужно решить новую задачу, а именно безопасно доставить файл от HTTP-клиента до готового сервиса.

Чтобы понять, какая новая задача появилась при переходе к HTTP API, сначала вспомним, как мы запускали анализ раньше. В файле document_ai_demo.py мы заранее указывали путь к документу:

SAMPLE_FILES_ROOT / "return_policy.pdf"

Такой код работает, потому что демонстрационный файл и каталог sample_files находятся на одном компьютере. А Python открывает известный локальный путь и читает файл с диска.

При работе через HTTP API пользователь и наше FastAPI-приложение обычно находятся на разных компьютерах. Например, пользователь открывает веб-интерфейс на своём ноутбуке, а приложение запущено на удалённом сервере.

Если пользователь выбрал файл по адресу /Users/student/Documents/report.pdf, этот путь существует только на его ноутбуке. Он сообщает, где файл хранится на устройстве пользователя, но ничего не говорит о расположении файла на сервере. FastAPI не сможет открыть такой путь, потому что у удалённого сервера нет доступа к файловой системе пользовательского компьютера.

Поэтому клиент должен передать серверу не строку с локальным путём, а само содержимое файла. Сервер примет его через HTTP-запрос и временно сохранит в своей файловой системе. После этого у нашего приложения появится доступный ему путь, который уже можно передать в DocumentAIService.

Временное сохранение здесь является переходом между двумя представлениями одного файла. На границе HTTP мы получаем поток байтов, а созданный ранее сервис ожидает путь. Мы не меняем внутреннее устройство DocumentAIService только потому, что у приложения появился новый интерфейс.

Как файл передаётся внутри HTTP-запроса?

До сих пор наши POST-запросы в основном содержали JSON. И JSON хорошо подходит для текста, чисел, логических значений, списков и объектов. Теоретически файл тоже можно закодировать в строку, но она станет больше исходных данных, а клиенту и серверу придётся выполнять лишнее преобразование.

Для загрузки файлов обычно используют тип тела multipart/form-data. Слово multipart означает, что тело запроса разделено на несколько частей. Каждая часть имеет своё имя и содержит отдельное значение.

В нашем запросе будут находиться три части:

multipart/form-data
├── file                  → содержимое return_policy.pdf
├── question              → "Каков срок возврата?"
└── require_human_review  → false

Поле file содержит байты и сведения о загруженном файле. question передаёт вопрос пользователя, а require_human_review позволяет прикладному сценарию потребовать обязательную проверку результата человеком.

Файл, вопрос и признак обязательной проверки передаются в одном HTTP-запросе, но его тело имеет формат multipart/form-data, а не JSON. Поэтому здесь мы не создаём одну входную Pydantic-модель наподобие ChatReplyRequest. А FastAPI получит каждую часть формы отдельно: файл из поля file, вопрос из поля question, а логическое значение из поля require_human_review.

Загруженный файл FastAPI передаст эндпоинту в виде объекта UploadFile. Это ещё не путь к файлу и не результат его обработки. UploadFile можно представить как оболочку над полученной загрузкой и через неё приложение узнаёт имя и заявленный тип файла, а также получает доступ к его содержимому для дальнейшего чтения и временного сохранения.

Нам понадобятся три его свойства:

  • filename содержит имя, которое сообщил клиент, например return_policy.pdf
  • content_type содержит заявленный MIME type, например application/pdf
  • file даёт доступ к файлоподобному объекту, из которого можно читать байты

Файлоподобный объект называется так потому, что поддерживает привычные операции чтения, хотя нам не нужно знать, где именно FastAPI держит данные в конкретный момент. Небольшая загрузка может находиться в памяти, а более крупная — во временном файле, которым управляет фреймворк. Этот механизм помогает FastAPI не превращать каждую загрузку в один большой объект bytes.

Однако наличие UploadFile само по себе не решает задачу безопасной обработки. Если вызвать чтение без ограничения и попытаться получить весь файл одним значением, приложение всё равно может использовать слишком много памяти. Поэтому наш код будет читать file небольшими блоками и после каждого блока проверять общий размер.

прочитать часть
→ прибавить её размер к счётчику
→ проверить лимит
→ записать часть во временный файл
→ перейти к следующей части

Такой способ иногда называют потоковым или поэтапным чтением файла. Здесь слово «потоковое» не связано с SSE из предыдущего раздела. В SSE мы постепенно отправляли клиенту текст ответа. Теперь мы постепенно читаем входящие байты, чтобы не собирать весь документ в памяти нашего кода.

Почему данным UploadFile нельзя сразу доверять?

filename и content_type передаёт клиент. Пользователь или другая программа могут указать в них любые значения. Например, исполняемый файл можно переименовать в report.pdf, а в HTTP-запросе самостоятельно написать application/pdf.

Поэтому имя и MIME type являются не доказательством, а только заявлением клиента. Мы используем имя, чтобы сохранить расширение и показать понятный источник в результате, но предварительно уберём из него части пути. MIME type передадим уже существующему FileValidator, который сопоставит его с расширением.

Сам FileValidator дополнительно проверит фактическое содержимое. Для PDF он ищет сигнатуру PDF, для PNG и JPEG — сигнатуры изображений, для DOCX и XLSX — ожидаемую структуру ZIP-контейнера. Текстовые форматы проверяются на корректный UTF-8 и недопустимые нулевые байты.

Получается две разные проверки. Во время записи мы следим за именем и количеством реально прочитанных байтов. После записи FileValidator проверяет разрешённый путь, расширение, MIME type, размер и содержимое. Эти уровни не дублируют друг друга: первый безопасно принимает поток, а второй решает, можно ли передавать полученный файл дальше.

Важно понимать, что эти проверки не создают абсолютную защиту от любых вредоносных файлов. Мы строим несколько последовательных уровней защиты, достаточных для нашего учебного приложения. В реальном продукте к ним могут добавляться антивирусная проверка, запуск обработчиков в изолированной среде, ограничения HTTP-запроса на уровне обратного прокси-сервера, контроль времени обработки и отдельное хранилище с ограниченными правами доступа. Набор мер зависит от форматов файлов, возможного ущерба и требований конкретного проекта.

Где и как долго будет храниться загрузка?

Сохранять пользовательский файл рядом с исходным кодом или помещать его в sample_files не будем. Каталог sample_files содержит учебные материалы проекта, а загруженный документ принадлежит конкретному HTTP-запросу.

Для каждого запроса приложение создаст отдельный временный подкаталог. Файл будет существовать только внутри блока обработки: сначала запишется, затем пройдёт проверку и анализ, а после получения результата временный каталог удалится.

начало запроса
→ создать отдельный временный каталог
→ сохранить загруженный файл
→ проверить и проанализировать
→ сформировать HTTP-ответ
→ удалить временный каталог

Очистка должна выполняться не только после успешного ответа. Неподдерживаемый формат, повреждённый документ, ошибка ProxyAPI или исключение нашего кода не должны оставлять файл на диске. Для этой гарантии воспользуемся контекстным менеджером: выход из блока with запускает очистку как при успехе, так и при ошибке.

В учебном API временный файл живёт до завершения анализа. В реальных системах крупные загрузки нередко помещают в объектное хранилище, а обработку выполняют фоновой задачей. Такой сценарий требует отдельных статусов задания, постоянного хранилища и механизма очередей. Сейчас он только усложнил бы основную тему, поэтому мы реализуем понятную синхронную цепочку для одного ограниченного файла.

Что именно сделаем в разделе?

В этом разделе мы не будем заново изучать безопасность PDF, DOCX, изображений и архивов. Эти правила уже реализованы в четвёртом модуле. Новая часть начинается при получении UploadFile и заканчивается передачей временного пути в DocumentAIService.

В следующих шагах мы:

  • добавим единый лимит размера файла в конфигурацию
  • создадим класс для поэтапной записи загрузки во временный каталог
  • подключим файловые сервисы к ApplicationContainer
  • опишем типизированный HTTP-ответ и добавим POST /files/analyze
  • проверим успешный анализ, запрещённый формат, неправильный MIME type, превышение размера и очистку временных данных

Я думаю теперь вам общая цепочка понятна, FastAPI принимает загрузку как UploadFile, наш код ограниченно переносит её во временный файл, а существующий DocumentAIService выполняет всю прикладную обработку. В следующем шаге мы перейдём к коду и реализуем безопасную поэтапную запись входящего файла.

Шаг 2

Перед тем как переходить к написанию кода, давайте разберёмся, как мы будем сохранять полученный файл и контролировать его размер.

FastAPI передаст нам содержимое загрузки в виде потока байтов. Можно было бы вызвать read() без указания размера и сразу получить весь файл целиком. Однако тогда приложение сначала загрузит всё содержимое в память и только потом сможет узнать его фактический размер. Если клиент отправит очень большой файл, сервер потратит слишком много памяти ещё до того, как наш код обнаружит превышение лимита.

Предварительно определить точный размер по HTTP-заголовку Content-Length тоже нельзя. Такой заголовок может отсутствовать, а его значение приходит от клиента, поэтому не должно быть единственной проверкой.

Вместо этого будем читать файл небольшими блоками по 64 КБ. После чтения каждого блока прибавим количество полученных байтов к общему счётчику и сравним результат с установленным ограничением. Если лимит превышен, сразу остановим запись и вернём ошибку. Благодаря этому приложение проверяет не заявленный, а фактически полученный размер файла.

Откройте app/config.py и добавьте в Settings новое поле:

file_max_size_bytes: int = Field(
    default=10 * 1_024 * 1_024,
    gt=0,
    validation_alias="FILE_MAX_SIZE_BYTES",
)

По умолчанию один файл может занимать не более 10 МБ и мы выносим число в конфигурацию, чтобы одинаковое ограничение использовалось и при первичной записи HTTP-потока, и при последующей проверке файла.

Теперь создайте файл app/files/upload_storage.py. В начале опишем ошибки и класс, который будет заниматься временной записью:

from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import BinaryIO

class UploadedFileStorageError(RuntimeError):
    pass

class UploadedFileNameError(UploadedFileStorageError):
    pass

class UploadedFileEmptyError(UploadedFileStorageError):
    pass

class UploadedFileSizeError(UploadedFileStorageError):
    pass

class UploadedFileStorage:
    def __init__(
        self,
        root: Path,
        max_size_bytes: int,
    ) -> None:
        if max_size_bytes <= 0:
            raise ValueError(
                "max_size_bytes должен быть больше нуля."
            )

        try:
            resolved_root = root.resolve(strict=True)
        except OSError as error:
            raise ValueError(
                "Каталог загрузок недоступен."
            ) from error

        if not resolved_root.is_dir():
            raise ValueError(
                "Каталог загрузок должен быть каталогом."
            )

        self._root = resolved_root
        self._max_size_bytes = max_size_bytes

Параметр root содержит путь к общему временному каталогу, предназначенному для всех загружаемых файлов. При создании UploadedFileStorage класс проверяет, что этот путь существует и действительно ведёт к каталогу.

Сам файл не будет сохраняться непосредственно в root. Для каждого HTTP-запроса внутри него создаётся отдельный временный подкаталог:

общий временный каталог
├── загрузка-1
│   └── report.pdf
└── загрузка-2
    └── report.pdf

Поэтому несколько пользователей могут одновременно загрузить файлы с одинаковым именем report.pdf, но они окажутся в разных подкаталогах и не перезапишут друг друга. После завершения обработки подкаталог конкретной загрузки будет удалён вместе с находящимся в нём файлом.

После конструктора добавьте метод stage():

@contextmanager
def stage(
    self,
    source: BinaryIO,
    original_name: str | None,
) -> Iterator[Path]:
    safe_name = self._safe_name(original_name)

    try:
        temporary_context = TemporaryDirectory(
            prefix="ai-support-upload-",
            dir=self._root,
        )
    except OSError as error:
        raise UploadedFileStorageError(
            "Не удалось создать каталог загрузки."
        ) from error

    with temporary_context as temporary_directory:
        temporary_path = Path(temporary_directory) / safe_name
        copied_bytes = 0

        try:
            source.seek(0)
            with temporary_path.open("xb") as target:
                while chunk := source.read(64 * 1_024):
                    copied_bytes += len(chunk)
                    if copied_bytes > self._max_size_bytes:
                        raise UploadedFileSizeError(
                            "Файл превышает разрешённый размер."
                        )
                    target.write(chunk)
        except UploadedFileSizeError:
            raise
        except OSError as error:
            raise UploadedFileStorageError(
                "Не удалось сохранить загруженный файл."
            ) from error

        if copied_bytes == 0:
            raise UploadedFileEmptyError(
                "Пустой файл нельзя обработать."
            )

        yield temporary_path

Метод stage() нужен как промежуточный этап между FastAPI и DocumentAIService. FastAPI передаёт загруженный файл в виде потока байтов, а DocumentAIService ожидает обычный путь Path. Поэтому stage() читает входящий поток небольшими блоками, контролирует фактический размер и сохраняет содержимое во временный файл.

После завершения записи строка yield temporary_path передаёт вызывающему коду путь к этому файлу. Пока выполняется блок with, временный файл существует и его можно передать в DocumentAIService:

with upload_storage.stage(source, original_name) as temporary_path:
    result = document_ai_service.analyze(...)

Когда выполнение выходит из блока with, временный каталог вместе с загруженным файлом автоматически удаляется. Очистка происходит как после успешного анализа, так и при возникновении исключения. Таким образом, stage() объединяет в одном месте ограниченное чтение потока, временное хранение и гарантированное удаление файла.

После stage() добавьте последний внутренний метод:

@staticmethod
def _safe_name(original_name: str | None) -> str:
    normalized = (original_name or "").strip().replace("\\", "/")
    safe_name = normalized.rsplit("/", maxsplit=1)[-1]

    if safe_name in {"", ".", ".."} or "\x00" in safe_name:
        raise UploadedFileNameError(
            "Клиент не передал корректное имя файла."
        )

    return safe_name

Имя загруженного файла также приходит от клиента, поэтому использовать его без проверки нельзя. Обычно клиент передаёт простое значение вроде report.pdf, но вместо него может прийти полный путь или строка с переходами между каталогами, например ../../report.pdf либо C:\Documents\report.pdf.

Метод _safe_name() приводит оба варианта разделителей к одному виду, удаляет все части пути и оставляет только имя файла report.pdf. Также он отклоняет пустое и некорректное имя. После этого файл сохраняется внутри отдельного временного каталога, созданного нашим приложением. Поэтому клиент не может самостоятельно выбрать место записи на сервере.

При этом UploadedFileStorage отвечает только за безопасное временное сохранение, а именно проверяет имя, читает поток по частям и контролирует размер. Он ещё не определяет, действительно ли внутри находится разрешённый PDF, DOCX или другой поддерживаемый формат. Расширение, MIME Type и фактическое содержимое позже проверит уже созданный FileValidator.

В следующем шаге мы подключим оба компонента к общей сборке приложения и UploadedFileStorage сначала подготовит временный файл, а DocumentAIService с помощью FileValidator проверит и обработает его.

Шаг 3

В предыдущем шаге мы создали UploadedFileStorage, который сохраняет полученный по HTTP файл во временном каталоге. Однако пока этот класс существует отдельно: build_application() не создаёт его, а HTTP-эндпоинт не сможет получить готовый объект из контейнера.

Похожая ситуация сложилась с DocumentAIService. Функция build_document_ai_service() уже умеет собирать сервис со всеми валидаторами, экстракторами и анализаторами, но полученный объект пока не входит в ApplicationContainer.

В этом шаге мы исправим это и добавим оба объекта в общую сборку приложения. После этого будущий эндпоинт сможет получить из контейнера всё необходимое, а именно UploadedFileStorage подготовит временный файл, а DocumentAIService проверит и проанализирует его. Такой же подход уже используется маршрутами чата и анализа обращений, которые получают готовые сервисы, а не создают их при каждом HTTP-запросе.

Перед сборкой уточним контракт DocumentAIRequest. HTTP-клиент сообщает MIME Type загрузки, и его нужно передать FileValidator. Откройте app/services/document_ai_service.py и добавьте в DocumentAIRequest после question новое поле:

@dataclass(frozen=True)
class DocumentAIRequest:
    source_path: Path
    question: str
    declared_media_type: str | None = None
    sheet_name: str | None = None
    table_plan: TableAnalysisPlan | None = None
    document_references: tuple[str, ...] | None = None
    additional_document_paths: tuple[Path, ...] = ()
    additional_image_paths: tuple[Path, ...] = ()
    require_human_review: bool = False

Поле declared_media_type остаётся необязательным, потому что DocumentAIService может использоваться не только через HTTP API. При загрузке через FastAPI мы получим MIME type из UploadFile и передадим его сервису. Если же сервис вызывается напрямую из Python-кода и MIME type неизвестен, можно оставить None. В этом случае FileValidator всё равно проверит расширение и фактическое содержимое файла.

В методе _prepare_from_file() найдите первую проверку пути:

validated = self._validator.validate(request.source_path)

Замените её вызовом, который также передаёт заявленный тип:

validated = self._validator.validate(
    request.source_path,
    declared_media_type=request.declared_media_type,
)

Теперь FileValidator проверит файл сразу по нескольким признакам. Сначала он посмотрит на расширение, например .pdf. Затем сравнит его с MIME type, который клиент передал вместе с загрузкой, например application/pdf. После этого валидатор проверит само содержимое файла: действительно ли оно начинается с ожидаемой сигнатуры PDF.

Проверка будет пройдена только в том случае, если эти признаки не противоречат друг другу. То есть одного названия report.pdf или указанного клиентом типа application/pdf недостаточно, потому что оба значения можно подделать.

Дальше откройте app/application.py. К стандартным импортам добавьте TemporaryDirectory, а к импортам проекта — новое хранилище:

from tempfile import TemporaryDirectory

from app.files.upload_storage import UploadedFileStorage

В сигнатуру build_document_ai_service() добавьте общий лимит:

def build_document_ai_service(
        llm_client: LLMClient,
        allowed_root: Path,
        max_size_bytes: int = 10 * 1_024 * 1_024,
) -> DocumentAIService:

И сразу ниже найдите создание FileValidationPolicy и замените зашитое число на параметр:

file_policy = FileValidationPolicy(
    allowed_root=allowed_root,
    max_size_bytes=max_size_bytes,
)

Эта функция по-прежнему может вызываться с двумя аргументами из document_ai_demo.py. А HTTP-приложение передаст сюда значение из Settings.

Дополните ApplicationContainer тремя полями:

@dataclass
class ApplicationContainer:
    settings: Settings
    chat_service: ChatService
    request_analyzer: SupportRequestAnalyzer
    document_ai_service: DocumentAIService
    upload_storage: UploadedFileStorage
    llm_client: LLMClient
    tool_executor: ToolExecutor
    upload_runtime: TemporaryDirectory[str]

document_ai_service выполнит сам анализ, upload_storage подготовит полученный файл, а upload_runtime владеет общим временным каталогом. Последний объект нужно хранить до остановки всего приложения. Если объект TemporaryDirectory потерять раньше, Python может удалить каталог, пока сервер ещё работает.

В методе close() нужно освободить новый ресурс даже тогда, когда закрытие другого объекта завершилось ошибкой. Поэтому замените метод целиком:

def close(self) -> None:
    try:
        self.tool_executor.close()
    finally:
        try:
            self.llm_client.close()
        finally:
            self.upload_runtime.cleanup()

Теперь перейдите к build_application(). Сразу после создания llm_client создайте общий каталог и два файловых объекта:

llm_client = LLMClient(settings)

upload_runtime = TemporaryDirectory(
    prefix="ai-support-api-"
)
upload_root = Path(upload_runtime.name)

upload_storage = UploadedFileStorage(
    root=upload_root,
    max_size_bytes=settings.file_max_size_bytes,
)
document_ai_service = build_document_ai_service(
    llm_client=llm_client,
    allowed_root=upload_root,
    max_size_bytes=settings.file_max_size_bytes,
)

Оба объекта получают один и тот же upload_root и одинаковый лимит. UploadedFileStorage имеет право записывать файлы в этот каталог, а FileValidator внутри DocumentAIService разрешает читать только из него. Так путь, полученный от клиента в виде имени файла, не даёт доступа к произвольному месту на сервере.

В конце build_application() добавьте новые значения в возвращаемый контейнер:

return ApplicationContainer(
    settings=settings,
    chat_service=chat_service,
    request_analyzer=request_analyzer,
    document_ai_service=document_ai_service,
    upload_storage=upload_storage,
    llm_client=llm_client,
    tool_executor=tool_executor,
    upload_runtime=upload_runtime,
)

Теперь build_application() создаёт не только чат и анализатор обращений, но и все объекты, необходимые для работы с загружаемыми файлами. Благодаря этому при запуске FastAPI файловые сервисы уже будут находиться внутри ApplicationContainer, и эндпоинту останется только получить их через зависимость.

Отдельно изменять app/api/lifespan.py не требуется. В начале работы приложения lifespan уже вызывает build_application() и сохраняет созданный контейнер. При остановке сервера он вызывает container.close(), который закрывает клиенты и удаляет общий временный каталог:

запуск FastAPI
→ lifespan
→ build_application()
→ создание контейнера и временного каталога

остановка FastAPI
→ lifespan
→ container.close()
→ закрытие ресурсов и удаление временного каталога

Корневой main.py для консольного запуска также использует build_application(). Поэтому он тоже создаст обновлённый контейнер. Консольный интерфейс не принимает файлы и просто не будет обращаться к document_ai_service и upload_storage. На работу чата это не повлияет, а при завершении программы вызов application.close() освободит все созданные ресурсы, включая временный каталог.

Мы подготовили зависимости и связали их время жизни со временем жизни всего приложения. В следующем шаге мы добавим HTTP-контракт и эндпоинт, который впервые проведёт загруженный файл по всей готовой цепочке.

Шаг 4

Все необходимые компоненты уже создаются при запуске приложения и хранятся в ApplicationContainer. Поэтому теперь можно связать загрузку файла с DocumentAIService через новый HTTP-эндпоинт. Он будет принимать файл, вопрос пользователя и необязательный признак обязательной проверки результата человеком.

При этом сам эндпоинт не должен знать, как читать PDF или DOCX, готовить контекст, составлять промпт и проверять ответ модели. Всю эту работу уже выполняет DocumentAIService. HTTP-слою достаточно сохранить загрузку во временном каталоге, создать DocumentAIRequest, вызвать готовый сервис и сформировать ответ для клиента.

Сначала опишем этот ответ с помощью Pydantic-моделей. Благодаря им FastAPI сможет проверить возвращаемые данные, добавить структуру результата в документацию и показать клиенту постоянный формат JSON независимо от содержимого конкретного файла.

Сначала опишем JSON, который получит клиент. Создайте файл app/api/schemas/documents.py и добавьте:

from pydantic import BaseModel, Field

from app.api.schemas.common import TokenUsageResponse

class DocumentFactResponse(BaseModel):
    text: str
    reference: str

class DocumentAnalysisResponse(BaseModel):
    summary: str
    answer: str
    key_facts: list[DocumentFactResponse]
    limitations: list[str]
    requires_human_review: bool

class DocumentAnalyzeResponse(BaseModel):
    source_name: str
    source_kind: str
    warnings: list[str]
    analysis: DocumentAnalysisResponse
    model: str
    prompt_id: str
    prompt_version: str
    model_calls: int = Field(ge=1)
    elapsed_seconds: float = Field(ge=0)
    usage: TokenUsageResponse
    response_id: str

DocumentAnalysisResponse хранит полезный результат для пользователя: краткое содержание, ответ на вопрос, факты со ссылками на части документа, ограничения и признак ручной проверки. DocumentAnalyzeResponse добавляет сведения об источнике и генерации. По этим полям можно понять, какой файл был обработан, какая модель ответила, сколько было вызовов и токенов.

Отдельную Pydantic-модель для входных данных здесь создавать не будем, потому что клиент отправляет не JSON, а форму multipart/form-data. FastAPI отдельно получит из неё загруженный файл, вопрос пользователя и признак обязательной проверки человеком.

Теперь, когда формат входных и выходных данных понятен, можно написать сам HTTP-маршрут и связать полученный UploadFile с уже готовым DocumentAIService. Для этого создайте app/api/routes/documents.py и напишите в него следующий код:

from typing import Annotated

from fastapi import (
    APIRouter,
    Depends,
    File,
    Form,
    UploadFile,
    status,
)

from app.api.dependencies import get_container
from app.api.errors import ApiError
from app.api.schemas.common import TokenUsageResponse
from app.api.schemas.documents import (
    DocumentAnalysisResponse,
    DocumentAnalyzeResponse,
    DocumentFactResponse,
)
from app.api.schemas.errors import ErrorResponse
from app.application import ApplicationContainer
from app.files.upload_storage import (
    UploadedFileEmptyError,
    UploadedFileNameError,
    UploadedFileSizeError,
    UploadedFileStorageError,
)
from app.services.document_ai_service import (
    DocumentAIInputError,
    DocumentAIModelError,
    DocumentAIPreparationError,
    DocumentAIRequest,
)

router = APIRouter(tags=["documents"])

Эндпоинт объявим обычной синхронной функцией def, так как остальные сервисы нашего проекта тоже работают синхронно, да и LLMClient использует синхронное подключение, DocumentAIService ожидает ответ модели, а файловые компоненты читают и записывают данные обычными синхронными методами. FastAPI запускает синхронные эндпоинты в отдельных рабочих потоках. Поэтому во время чтения документа и ожидания ответа ProxyAPI основной асинхронный цикл сервера продолжает принимать другие HTTP-запросы.

После router добавьте сам эндпоинт. Внутри try пока оставим только основной успешный сценарий:

@router.post(
    "/files/analyze",
    responses={
        413: {
            "model": ErrorResponse,
            "description": "Файл превышает лимит.",
        },
        422: {
            "model": ErrorResponse,
            "description": "Файл или параметры не прошли проверку.",
        },
        500: {
            "model": ErrorResponse,
            "description": "Временный файл не подготовлен.",
        },
        502: {
            "model": ErrorResponse,
            "description": "AI-анализ не выполнен.",
        },
    },
)
def analyze_file(
    file: Annotated[UploadFile, File()],
    question: Annotated[
        str,
        Form(min_length=1, max_length=4_000),
    ],
    container: Annotated[
        ApplicationContainer,
        Depends(get_container),
    ],
    require_human_review: Annotated[bool, Form()] = False,
) -> DocumentAnalyzeResponse:
    try:
        with container.upload_storage.stage(
            source=file.file,
            original_name=file.filename,
        ) as temporary_path:
            result = container.document_ai_service.analyze(
                DocumentAIRequest(
                    source_path=temporary_path,
                    question=question,
                    declared_media_type=file.content_type,
                    require_human_review=require_human_review,
                )
            )

В этом коде File() получает загруженный файл, а Form() остальные поля формы. Через get_container() FastAPI передаёт эндпоинту готовые сервисы.

Метод stage() временно сохраняет файл и возвращает путь к нему. Затем мы создаём DocumentAIRequest и передаём его в DocumentAIService, который проверяет и анализирует документ. После выхода из блока with временный файл автоматически удаляется.

Сразу после вызова analyze(), добавьте обработку ожидаемых ошибок. Эти блоки относятся к тому же try:

    except UploadedFileSizeError as error:
        raise ApiError(
            status_code=status.HTTP_413_CONTENT_TOO_LARGE,
            code="file_too_large",
            message=str(error),
        ) from error
    except (
        UploadedFileEmptyError,
        UploadedFileNameError,
        DocumentAIInputError,
        DocumentAIPreparationError,
    ) as error:
        raise ApiError(
            status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
            code="invalid_file",
            message=str(error),
        ) from error
    except UploadedFileStorageError as error:
        raise ApiError(
            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
            code="file_storage_failed",
            message="Не удалось подготовить файл.",
        ) from error
    except DocumentAIModelError as error:
        raise ApiError(
            status_code=status.HTTP_502_BAD_GATEWAY,
            code="document_analysis_failed",
            message="Не удалось проанализировать файл.",
        ) from error

Превышение размера превращается в 413 Content Too Large. Пустой файл, неверное имя, неподдерживаемый формат или повреждённое содержимое в 422 Unprocessable Content. Ошибка записи на диск является внутренней проблемой сервера, поэтому получает 500. Если файл уже подготовлен, но не удалось получить AI-анализ, возвращаем 502. Тут главное не передавать технические детали провайдера в ответ.

После блоков except сформируйте успешный ответ:

    return DocumentAnalyzeResponse(
        source_name=result.source_name,
        source_kind=str(result.source_kind),
        warnings=list(result.warnings),
        analysis=DocumentAnalysisResponse(
            summary=result.analysis.summary,
            answer=result.analysis.answer,
            key_facts=[
                DocumentFactResponse(
                    text=fact.text,
                    reference=fact.reference,
                )
                for fact in result.analysis.key_facts
            ],
            limitations=result.analysis.limitations,
            requires_human_review=(
                result.analysis.requires_human_review
            ),
        ),
        model=result.model,
        prompt_id=result.prompt_id,
        prompt_version=result.prompt_version,
        model_calls=result.model_calls,
        elapsed_seconds=result.elapsed_seconds,
        usage=TokenUsageResponse(
            prompt_tokens=result.prompt_tokens,
            completion_tokens=result.completion_tokens,
            total_tokens=result.total_tokens,
        ),
        response_id=result.response_id,
    )

Мы явно переносим данные из внутреннего DocumentAIResult в HTTP-модель. Это не новый анализ и не повторная проверка. Маршрут просто выбирает, какие поля станут публичным HTTP-контрактом. FastAPI после этого проверит результат и преобразует его в JSON.

Осталось подключить маршрут к FastAPI. Откройте app/api/main.py и добавьте импорт:

from app.api.routes.documents import router as documents_router

Внутри create_api(), рядом с другими вызовами include_router(), подключите новую группу маршрутов:

application.include_router(documents_router)

Теперь весь путь собран. HTTP-слой примет данные формы, UploadedFileStorage подготовит ограниченную временную копию, а DocumentAIService проведёт уже знакомую проверку и AI-анализ. Давайте в следующем шаге мы запустим наш проект и проверим его работу.

Шаг 5

В этом разделе мы добавили немало кода, а именно потоковую запись, временные каталоги, новые поля контейнера, HTTP-модели и маршрут. Сейчас проверим главный сценарий целиком.

Запустите сервер из корня проекта:

fastapi dev app/api/main.py

Во время старта lifespan создаст ApplicationContainer, а build_application() общий временный каталог и сервис анализа файлов.

И давайте начнем с успешного анализа. Откройте http://127.0.0.1:8000/docs и найдите эндпоинт POST /files/analyze. Нажмите Try it out, выберите из sample_files файл return_policy.pdf и задайте вопрос:

Каков срок возврата и какие сведения нужно указать?

А параметр require_human_review оставьте равным false.

После отправки появится JSON с данными об источнике, самим анализом и метаданными генерации. Точный текст зависит от модели, но форма ответа должна остаться постоянной:

{
  "source_name": "return_policy.pdf",
  "source_kind": "pdf",
  "warnings": [],
  "analysis": {
    "summary": "В документе описаны правила возврата товара, включая условия возврата исправного и повреждённого товара, а также необходимые действия и сроки для разных ситуаций.",
    "answer": "В документе нет информации, позволяющей однозначно ответить на ваш вопрос, так как \"string\" не является конкретным вопросом. Пожалуйста, уточните ваш запрос.",
    "key_facts": [
      {
        "text": "Покупатель может вернуть товар, если он не подошёл, имеет недостатки или не соответствует описанию.",
        "reference": "page:1"
      },
      {
        "text": "Порядок и сроки возврата зависят от состояния товара и причины обращения.",
        "reference": "page:1"
      },
      {
        "text": "Для оформления возврата необходимо указать номер заказа и предоставить сведения о товаре.",
        "reference": "page:1"
      },
      {
        "text": "Если товар был повреждён во время доставки, покупателю необходимо обратиться в поддержку и приложить фотографии.",
        "reference": "page:1"
      },
      {
        "text": "Исправный товар можно вернуть в течение 14 дней после получения, если он сохранил товарный вид, комплектность и потребительские свойства.",
        "reference": "page:1"
      }
    ],
    "limitations": [
      "Вопрос не конкретизирован, поэтому невозможно дать точный ответ.",
      "В документе нет информации о термине или понятии \"string\"."
    ],
    "requires_human_review": true
  },
  "model": "gpt-4.1-2025-04-14",
  "prompt_id": "document_analysis",
  "prompt_version": "1.0.0",
  "model_calls": 1,
  "elapsed_seconds": 3.107725042034872,
  "usage": {
    "prompt_tokens": 585,
    "completion_tokens": 269,
    "total_tokens": 854
  },
  "response_id": "chatcmpl-EL8HpKGJPEUQkOGSVksc3E6DIzakz"
}

Числа в примере условны. Проверяем не совпадение токенов и времени, а смысл: source_name совпадает с загруженным файлом, факты ссылаются на реальные части, а ответ соответствует тексту документа.

Тот же запрос можно выполнить из терминала:

curl -X POST \
  -F "file=@sample_files/return_policy.pdf;type=application/pdf" \
  -F "question=Каков срок возврата?" \
  -F "require_human_review=false" \
  http://127.0.0.1:8000/files/analyze

В отличие от предыдущих JSON-запросов, здесь каждый параметр передаётся через -F. Сам curl соберёт из этих частей multipart/form-data.

Проверяем ошибки до обращения к модели

Создайте в корне проекта небольшой файл unsupported.json с любым текстом и отправьте его в тот же эндпоинт. FileValidator не найдёт .json в списке разрешённых форматов. Сервер вернёт 422 с кодом invalid_file, а запрос к ProxyAPI не будет выполнен.

Отдельно проверьте несовпадение MIME type. Отправьте настоящий PDF, но заявите его как обычный текст:

curl -X POST \
  -F "file=@sample_files/return_policy.pdf;type=text/plain" \
  -F "question=О чём этот файл?" \
  http://127.0.0.1:8000/files/analyze

Расширение .pdf и сигнатура PDF правильные, но заявленный text/plain им противоречит. Валидатор остановит обработку до извлечения текста и до обращения к модели.

Для проверки лимита создайте временный текстовый файл размером 11 МБ:

python -c "from pathlib import Path; Path('oversized.txt').write_bytes(b'a' * 11 * 1024 * 1024)"

Отправьте его через Swagger UI или curl.

Мы видим что UploadedFileStorage прекращает чтение сразу после превышения 10 МБ. И мы получаем ожидаемый статус 413, а код ошибки — file_too_large. После проверки удалите oversized.txt, он не нужен проекту.

Важно различать два ограничения. FastAPI и веб-сервер сначала принимают HTTP-тело и разбирают форму. Наш UploadedFileStorage ограничивает объём одного файла уже внутри приложения. В продакшене общий размер HTTP-запроса также ограничивают на уровне reverse proxy или API gateway. Но в любом случае эта внешняя настройка не отменяет проверку в коде.

На этом загрузка и анализ файлов через HTTP API готовы. Мы не переписывали экстракторы, валидаторы и AI-анализаторы. А просто FastAPI стал новым входом в уже существующую прикладную логику.

До этого момента мы проверяли работу проекта вручную, а именно запускали приложение, отправляли запросы через Swagger UI или curl и самостоятельно оценивали полученные ответы. Такой способ помогает увидеть реальное поведение приложения, но с ростом проекта повторять все прежние сценарии после каждого изменения становится всё сложнее.

Поэтому в следующем разделе мы перейдём к автоматическим тестам. Они будут самостоятельно запускать нужные части приложения без реальных обращений к ProxyAPI и проверять HTTP-статусы, форматы ответов, работу сессий, SSE-события и удаление временных файлов.

Шаг 6

Как FastAPI получает одновременно файл и текстовый вопрос в маршруте анализа?

Правильный ответ
  • ✅ Как multipart/form-data: файл через File, вопрос через Form

Шаг 7

Каким данным объекта UploadFile нельзя безусловно доверять?

Выберите все правильные варианты.

Правильный ответ
  • ✅ Размеру, если он не проверен во время чтения
  • ✅ Имени файла, присланному клиентом
  • ✅ Заявленному MIME type

Шаг 8

Какой статус подходит для файла, размер которого превышает разрешённый лимит?

Правильный ответ
  • ✅ 413 Content Too Large

Шаг 9

Когда временный загруженный файл должен быть удалён?

Правильный ответ
  • ✅ После выхода из контекстного менеджера и при успехе, и при исключении

Шаг 10

Получение загруженного файла в FastAPI

В заготовке уже создан объект app — это FastAPI-приложение, которое принимает HTTP-запросы. Вам нужно добавить в него адрес POST /files/info. Клиент отправит на этот адрес файл, а функция-эндпоинт вернёт его имя и заявленный тип содержимого.

Задача

Добавьте синхронную функцию-эндпоинт file_info, которая получает файл через параметр FastAPI UploadFile и возвращает словарь с его метаданными.

Требования

  • Не изменяйте уже созданный объект app и существующие импорты.
  • Зарегистрируйте функцию декоратором @app.post("/files/info").
  • Назовите функцию-эндпоинт file_info и объявите её через обычный def.
  • Добавьте параметр file с аннотацией Annotated[UploadFile, File()].
  • Верните словарь ровно с ключами filename и content_type.
  • В filename сохраните file.filename, а в content_type — file.content_type.
  • Если клиент не передал поле file, FastAPI должен самостоятельно вернуть ошибку валидации со статусом 422.

Пример результата

Запрос:
POST /files/info
Поле file: report.txt
Content-Type файла: text/plain

Ответ:
200 OK
{
  "filename": "report.txt",
  "content_type": "text/plain"
}
Правильный ответ
from typing import Annotated

from fastapi import FastAPI, File, UploadFile

app = FastAPI()

@app.post("/files/info")
def file_info(file: Annotated[UploadFile, File()]):
    return {
        "filename": file.filename,
        "content_type": file.content_type,
    }

Шаг 11

Файл и поля формы в одном эндпоинте

Эндпоинт анализа получает не только документ, но и вопрос пользователя с признаком обязательной проверки результата человеком. Эти значения приходят в одном запросе multipart/form-data: файл обрабатывается через File(), а обычные значения — через Form().

Задача

Добавьте в готовое FastAPI-приложение синхронную функцию-эндпоинт preview_file_analysis по адресу POST /files/preview. Пока она не анализирует содержимое, а возвращает принятые значения, чтобы показать правильную обработку multipart-формы.

Требования

  • Не изменяйте объект app и существующие импорты.
  • Зарегистрируйте функцию декоратором @app.post("/files/preview").
  • Назовите функцию preview_file_analysis и объявите её через обычный def.
  • Параметр file должен иметь аннотацию Annotated[UploadFile, File()].
  • Параметр question должен иметь аннотацию Annotated[str, Form(min_length=1, max_length=4_000)].
  • Параметр require_human_review должен иметь аннотацию Annotated[bool, Form()] и значение по умолчанию False.
  • Верните словарь ровно с ключами filename, question и require_human_review.
  • Сохраните в них соответственно file.filename, question и require_human_review.
  • При отсутствии обязательного поля question FastAPI должен вернуть статус 422.

Пример результата

Запрос:
POST /files/preview
file: request.txt
question: "Что случилось с заказом?"
require_human_review: "true"

Ответ:
200 OK
{
  "filename": "request.txt",
  "question": "Что случилось с заказом?",
  "require_human_review": true
}
Правильный ответ
from typing import Annotated

from fastapi import FastAPI, File, Form, UploadFile

app = FastAPI()

@app.post("/files/preview")
def preview_file_analysis(
    file: Annotated[UploadFile, File()],
    question: Annotated[str, Form(min_length=1, max_length=4_000)],
    require_human_review: Annotated[bool, Form()] = False,
):
    return {
        "filename": file.filename,
        "question": question,
        "require_human_review": require_human_review,
    }

Шаг 12

Безопасное имя загруженного файла

В функцию safe_upload_name передаётся имя файла из HTTP-запроса, которому нельзя доверять. Вам нужно оставить только имя файла без каталогов и отклонить значения, из которых невозможно получить корректное имя.

Задача

Реализуйте функцию safe_upload_name(original_name). Она должна нормализовать полученное значение, удалить путь к файлу и вернуть безопасное имя либо выбросить UploadedFileNameError.

Требования

  • Не изменяйте имя функции и аргумент original_name.
  • Аргумент может содержать строку или None.
  • Замените обратные слеши \ на прямые / и удалите пробелы по краям строки.
  • Оставьте только часть строки после последнего символа /.
  • Если полученное имя равно пустой строке, "." или "..", выбросьте UploadedFileNameError.
  • Если имя содержит нулевой символ, выбросьте то же исключение.
  • Для всех ошибочных случаев используйте сообщение "Клиент не передал корректное имя файла.".
  • Для корректного значения верните имя без изменения регистра и расширения.

Пример результата

Исходное значение: " ../../Отчёт.PDF "
Результат: "Отчёт.PDF"

Исходное значение: "C:\temp\photo.png"
Результат: "photo.png"

Для None, "", ".." и строки с нулевым символом функция должна выбросить UploadedFileNameError.

Правильный ответ
# Это исключение предоставляет автопроверка.
from task_environment import UploadedFileNameError

def safe_upload_name(original_name: str | None) -> str:
    if original_name is None:
        raise UploadedFileNameError(
            "Клиент не передал корректное имя файла."
        )

    normalized = original_name.replace("\\", "/").strip()
    filename = normalized.rsplit("/", 1)[-1]

    if (
        filename in {"", ".", ".."}
        or "\x00" in filename
    ):
        raise UploadedFileNameError(
            "Клиент не передал корректное имя файла."
        )

    return filename

Шаг 13

Эндпоинт анализа загруженного файла

В заготовке уже создано FastAPI-приложение app. Также вам доступны контейнер с хранилищем и сервисом анализа, класс DocumentAIRequest и функция формирования публичного ответа. Вам нужно добавить настоящий эндпоинт POST /files/analyze, который примет файл и поля формы, временно сохранит загрузку и передаст её сервису.

Задача

Добавьте синхронную функцию-эндпоинт analyze_file. Свяжите внутри неё UploadFile, container.upload_storage и container.document_ai_service, а полученный результат преобразуйте функцией build_document_response().

Требования

  • Не изменяйте объект app и предоставленные импорты.
  • Зарегистрируйте функцию декоратором @app.post("/files/analyze").
  • Назовите функцию analyze_file и объявите её через обычный def.
  • Добавьте параметр file: Annotated[UploadFile, File()].
  • Добавьте параметр question: Annotated[str, Form(min_length=1, max_length=4_000)].
  • Получите контейнер через параметр container: Annotated[ApplicationContainer, Depends(get_container)].
  • Добавьте параметр require_human_review: Annotated[bool, Form()] = False.
  • Откройте container.upload_storage.stage(source=file.file, original_name=file.filename) как контекстный менеджер и получите temporary_path.
  • Пока временный файл существует, создайте DocumentAIRequest с полями source_path=temporary_path, question=question, declared_media_type=file.content_type и require_human_review=require_human_review.
  • Передайте созданный запрос в единственный вызов container.document_ai_service.analyze(...) и сохраните результат.
  • После анализа передайте результат в build_document_response(result) и верните полученный словарь.
  • Не вызывайте file.file.read() самостоятельно: сохранением байтов занимается upload_storage.

Пример результата

Запрос:
POST /files/analyze
file: request.txt
question: "Что произошло с заказом?"
require_human_review: "false"

Ответ:
200 OK
{
  "source_name": "request.txt",
  "source_kind": "text",
  "analysis": {
    "answer": "Товар был повреждён при доставке."
  }
}
Правильный ответ
from typing import Annotated

from fastapi import Depends, FastAPI, File, Form, UploadFile

# Эти объекты предоставляет автопроверка.
from task_environment import (
    ApplicationContainer,
    DocumentAIRequest,
    build_document_response,
    get_container,
)

app = FastAPI()

@app.post("/files/analyze")
def analyze_file(
    file: Annotated[UploadFile, File()],
    question: Annotated[str, Form(min_length=1, max_length=4_000)],
    container: Annotated[ApplicationContainer, Depends(get_container)],
    require_human_review: Annotated[bool, Form()] = False,
):
    with container.upload_storage.stage(
        source=file.file,
        original_name=file.filename,
    ) as temporary_path:
        request = DocumentAIRequest(
            source_path=temporary_path,
            question=question,
            declared_media_type=file.content_type,
            require_human_review=require_human_review,
        )

        result = container.document_ai_service.analyze(request)

    return build_document_response(result)

Шаг 14

Обработка ошибок файлового эндпоинта

В заготовке уже написан успешный сценарий эндпоинта POST /files/analyze: FastAPI принимает файл, временно сохраняет его и передаёт сервису анализа. Пока все исключения попадают в один временный блок except Exception. Вам нужно заменить его обработчиками ожидаемых ошибок, чтобы клиент получал правильный HTTP-статус и безопасное сообщение.

Задача

Дополните готовую функцию-эндпоинт analyze_file. Не меняйте код внутри try, но удалите временный блок except Exception и добавьте вместо него четыре обработчика, создающих ApiError.

Требования

  • Не изменяйте декоратор, сигнатуру функции и успешный сценарий внутри try.
  • UploadedFileSizeError преобразуйте в ApiError со статусом status.HTTP_413_CONTENT_TOO_LARGE, кодом "file_too_large" и сообщением str(error).
  • UploadedFileEmptyError, UploadedFileNameError, DocumentAIInputError и DocumentAIPreparationError обработайте одним блоком except. Используйте статус status.HTTP_422_UNPROCESSABLE_ENTITY, код "invalid_file" и сообщение str(error).
  • UploadedFileStorageError преобразуйте в ApiError со статусом status.HTTP_500_INTERNAL_SERVER_ERROR, кодом "file_storage_failed" и сообщением "Не удалось подготовить файл.".
  • DocumentAIModelError преобразуйте в ApiError со статусом status.HTTP_502_BAD_GATEWAY, кодом "document_analysis_failed" и сообщением "Не удалось проанализировать файл.".
  • Обработчик UploadedFileSizeError должен находиться раньше обработчика UploadedFileStorageError, потому что ошибка размера относится к ошибкам хранилища.
  • Не оставляйте общий блок except Exception.
  • Не включайте технические сообщения хранилища или AI-провайдера в ответы со статусами 500 и 502.

Пример результата

Слишком большой файл:

413 Content Too Large
{
  "error": {
    "code": "file_too_large",
    "message": "Размер файла превышает 10 МБ."
  }
}

Ошибка AI-провайдера:

502 Bad Gateway
{
  "error": {
    "code": "document_analysis_failed",
    "message": "Не удалось проанализировать файл."
  }
}
Правильный ответ
from typing import Annotated

from fastapi import Depends, FastAPI, File, Form, UploadFile, status

# Эти объекты предоставляет автопроверка.
from task_environment import (
    ApiError,
    ApplicationContainer,
    DocumentAIInputError,
    DocumentAIModelError,
    DocumentAIPreparationError,
    DocumentAIRequest,
    UploadedFileEmptyError,
    UploadedFileNameError,
    UploadedFileSizeError,
    UploadedFileStorageError,
    build_document_response,
    get_container,
)

app = FastAPI()

@app.post("/files/analyze")
def analyze_file(
    file: Annotated[UploadFile, File()],
    question: Annotated[str, Form(min_length=1, max_length=4_000)],
    container: Annotated[ApplicationContainer, Depends(get_container)],
    require_human_review: Annotated[bool, Form()] = False,
):
    try:
        with container.upload_storage.stage(
            source=file.file,
            original_name=file.filename,
        ) as temporary_path:
            result = container.document_ai_service.analyze(
                DocumentAIRequest(
                    source_path=temporary_path,
                    question=question,
                    declared_media_type=file.content_type,
                    require_human_review=require_human_review,
                )
            )
        return build_document_response(result)
    except UploadedFileSizeError as error:
        raise ApiError(
            status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
            "file_too_large",
            str(error),
        )
    except (
        UploadedFileEmptyError,
        UploadedFileNameError,
        DocumentAIInputError,
        DocumentAIPreparationError,
    ) as error:
        raise ApiError(
            status.HTTP_422_UNPROCESSABLE_ENTITY,
            "invalid_file",
            str(error),
        )
    except UploadedFileStorageError:
        raise ApiError(
            status.HTTP_500_INTERNAL_SERVER_ERROR,
            "file_storage_failed",
            "Не удалось подготовить файл.",
        )
    except DocumentAIModelError:
        raise ApiError(
            status.HTTP_502_BAD_GATEWAY,
            "document_analysis_failed",
            "Не удалось проанализировать файл.",
        )