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

Chat API и сессии

Шаг 1

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

Сейчас новое значение session_id неявно создаёт сессию внутри ChatService, но это не лучший вариант, ведь клиент может случайно допустить опечатку в идентификаторе и вместо продолжения диалога начать новый. Кроме того, через HTTP нельзя заранее создать пустой чат, получить его историю, очистить её или удалить завершённую сессию.

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

Под клиентом здесь может подразумеваться веб-страница, мобильное приложение или другой сервис. Например пользователь открывает страницу поддержки и нажимает «Новый чат». Интерфейс отправляет запрос на создание сессии, получает session_id и сохраняет его в своём состоянии. Пользователь сам не вводит и обычно даже не видит этот идентификатор.

Когда пользователь пишет сообщение, клиент сам подставляет сохранённый session_id в адрес HTTP-запроса. Сервер по нему находит историю, добавляет новую реплику и возвращает ответ. При обновлении страницы клиент может снова запросить состояние сессии и восстановить переписку на экране.

Важно разделять две зоны ответственности. Клиент хранит только идентификатор и отображаемые данные. История, резюме и ожидающее подтверждения действие остаются на сервере. Поэтому клиент не должен при каждом сообщении пересылать всю переписку заново — достаточно передать session_id и новую реплику.

И давайте сразу обговорим, а кто должен создавать Session ID? И так как это идентификатор нашего приложения, по которому ChatService находит конкретную историю разговора, то и создавать его должен сервер, владеющий хранилищем сессий.

Давайте перейдем к реализации, и первым делом откройте app/services/chat_service.py и добавьте импорт:

from uuid import uuid4

После ChatServiceError добавьте отдельную ошибку отсутствующей сессии:

class ChatSessionNotFoundError(ChatServiceError):
    pass

Раньше все проблемы чата представлялись одним классом ChatServiceError. Для консоли этого было достаточно, ведь она просто печатала текст ошибки. А HTTP API должен различать хотя бы две ситуации, а именно когда сбой обращения к модели станет ответом 502, а неизвестная сессия — ответом 404.

А так же раньше отдельного метода для создания сессии у ChatService не было, и идентификатор создавал внешний интерфейс. Например консоль самостоятельно вызывала uuid4().hex, сохраняла полученную строку и передавала её в stream_reply() при отправке сообщения. Но теперь изменим последовательность, и сначала клиент отдельно попросит сервер создать сессию, и уже отдельный метод сгенерирует идентификатор, сразу сохранит новый ChatSession и вернёт готовый session_id. Тем самым все последующие операции будут работать только с уже существующей сессией.

Давайте продолжим работать с тем же файлом, и в начало класса ChatService, перед методом reply(), добавьте:

def create_session(self) -> str:
    session_id = uuid4().hex

    with self._sessions_lock:
        self._sessions[session_id] = ChatSession()

    return session_id

Метод create_session() создаёт идентификатор, сохраняет под ним новый пустой ChatSession и возвращает строку вызывающему коду. А uuid4().hex все так же формирует 32 шестнадцатеричных символа.

Словарь self._sessions является общим для всех запросов, которые обслуживает один экземпляр ChatService. Два потока могут одновременно создавать разные диалоги, поэтому запись выполняется внутри self._sessions_lock. Эта блокировка защищает сам словарь, но не блокирует генерацию ответов в уже существующих сессиях.

Теперь нам понадобится обратная операция: найти существующую сессию и явно сообщить, если её нет. Рядом с прежним _get_or_create_session() добавьте:

def _require_session(
    self,
    session_id: str,
) -> ChatSession:
    with self._sessions_lock:
        session = self._sessions.get(session_id)

    if session is None:
        raise ChatSessionNotFoundError(
            "Чат-сессия не найдена."
        )

    return session

_require_session() ничего не создаёт. Метод ищет значение в словаре и возвращает найденный объект. Если идентификатор неизвестен, возникает ChatSessionNotFoundError. Благодаря этому опечатка в адресе больше не сможет незаметно открыть новый диалог.

Но _get_or_create_session() пока не удаляйте, ведь существующий маршрут из прошлого раздела всё ещё принимает идентификатор от клиента и использует старое поведение. В следующем шаге мы сначала добавим HTTP-операцию создания сессии, а затем переведём отправку сообщений на обязательный серверный идентификатор.

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

Шаг 2

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

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

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

Сразу перейдем к практике, в app/services/chat_service.py добавьте импорт Literal:

from typing import Literal

Перед внутренним классом ChatSession добавьте две модели:

@dataclass(frozen=True)
class ChatHistoryMessage:
    role: Literal["user", "assistant"]
    content: str

@dataclass(frozen=True)
class ChatSessionSnapshot:
    session_id: str
    messages: tuple[ChatHistoryMessage, ...]
    history_messages: int
    has_summary: bool
    pending_confirmation: bool

ChatHistoryMessage описывает одну публичную реплику. В сохранённой истории нас интересуют только роли user и assistant. Developer-инструкция, промежуточные вызовы инструментов и их технические результаты клиенту не возвращаются.

ChatSessionSnapshot представляет состояние диалога в конкретный момент. Кортеж messages нельзя случайно изменить после возврата из сервиса. Поле has_summary сообщает, что старая часть истории уже была сокращена, но само резюме наружу не передаётся. Оно является внутренним контекстом модели, а не сообщением пользователя. А поле pending_confirmation понадобится интерфейсу, чтобы показать кнопки подтверждения и отмены. При этом внутренний confirmation_id не раскрывается. Он хранится внутри сессии, и позднее клиент будет выполнять действие только через её адрес.

После create_session() добавьте публичный метод чтения:

def get_session(
    self,
    session_id: str,
) -> ChatSessionSnapshot:
    session_key = self._normalize_session_id(session_id)
    session = self._require_session(session_key)

    with session.lock:
        return self._build_session_snapshot(
            session_key,
            session,
        )

Сначала метод удаляет лишние пробелы по краям session_id, а затем с помощью _require_session() находит соответствующую сессию. После этого он захватывает session.lock и формирует отдельный объект с текущим состоянием диалога.

Напомню, блокировка нужна, чтобы история не изменилась прямо во время чтения. Без неё другой запрос мог бы одновременно добавить новые сообщения или изменить ожидающее действие. В результате часть полей относилась бы к старому состоянию, а часть — уже к новому. Внутри session.lock все необходимые данные копируются согласованно, после чего блокировка освобождается.

В нижней части класса, перед методом _normalize_session_id(), добавьте внутренний метод, который соберёт разрешённые данные сессии в отдельный объект ChatSessionSnapshot:

@classmethod
def _build_session_snapshot(
    cls,
    session_id: str,
    session: ChatSession,
) -> ChatSessionSnapshot:
    messages: list[ChatHistoryMessage] = []

    for message in session.messages:
        role = message["role"]
        content = message.get("content")

        if role not in {"user", "assistant"}:
            continue
        if not isinstance(content, str):
            continue

        messages.append(
            ChatHistoryMessage(
                role=role,
                content=cls._to_public_content(
                    role,
                    content,
                ),
            )
        )

    return ChatSessionSnapshot(
        session_id=session_id,
        messages=tuple(messages),
        history_messages=len(messages),
        has_summary=session.summary is not None,
        pending_confirmation=(
            session.pending_confirmation_id is not None
        ),
    )

Метод проходит по внутренней истории и копирует только обычные текстовые реплики. Дополнительные проверки нужны потому, что общий тип Message также допускает служебные роли и более сложное содержимое. Даже если они появятся внутри AI-сценария, публичная история останется предсказуемой.

Перед сохранением вопроса в историю ChatService оборачивает пользовательский текст в теги <customer_message>. Эти теги нужны только при общении с моделью, ведь они показывают, где находятся недоверенные данные пользователя, а не инструкции приложения. Например внутри сервиса сообщение хранится так:

<customer_message>
Монитор пришёл с треснутой подставкой.
</customer_message>

Но в интерфейсе пользователь должен увидеть только исходный текст:

Монитор пришёл с треснутой подставкой.

Поэтому при подготовке публичной истории будем удалять только те служебные теги, которые ранее добавил сам ChatService. После метода _build_session_snapshot() добавьте функцию для подготовки отображаемого текста:

@staticmethod
def _to_public_content(
    role: Literal["user", "assistant"],
    content: str,
) -> str:
    prefix = "<customer_message>\n"
    suffix = "\n</customer_message>"

    if (
        role == "user"
        and content.startswith(prefix)
        and content.endswith(suffix)
    ):
        return content[len(prefix):-len(suffix)]

    return content

Функция удаляет теги только тогда, когда пользовательское сообщение действительно начинается с <customer_message> и заканчивается </customer_message>. Если ожидаемых тегов нет, текст возвращается без изменений. Поэтому функция не пытается произвольно изменять содержимое сообщения, а убирает только известное служебное оформление перед передачей истории клиенту.

Теперь ChatService умеет возвращать безопасное состояние сессии, однако FastAPI всё ещё не знает, в каком виде эти данные должны передаваться HTTP-клиенту. Поэтому перейдём к HTTP-слою и создадим Pydantic-модели для двух новых операций, а именно создания сессии и получения её текущего состояния. Эти модели определят структуру JSON-ответов и появятся в документации Swagger UI.

Откройте app/api/schemas/chat.py, добавьте импорт:

from typing import Literal

После ChatReplyRequest добавьте:

class ChatSessionCreatedResponse(BaseModel):
    session_id: str

class ChatHistoryMessageResponse(BaseModel):
    role: Literal["user", "assistant"]
    content: str

class ChatSessionResponse(BaseModel):
    session_id: str
    messages: list[ChatHistoryMessageResponse]
    history_messages: int = Field(ge=0)
    has_summary: bool
    pending_confirmation: bool

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

Теперь откройте app/api/routes/chat.py. Дополните импорт схем:

from app.api.schemas.chat import (
    ChatHistoryMessageResponse,
    ChatReplyRequest,
    ChatReplyResponse,
    ChatSessionCreatedResponse,
    ChatSessionResponse,
)

И добавьте импорт новой ошибки:

from app.services.chat_service import (
    ChatServiceError,
    ChatSessionNotFoundError,
)

Перед существующим маршрутом ответа чата добавьте создание сессии:

@router.post(
    "/chat/sessions",
    status_code=status.HTTP_201_CREATED,
)
def create_chat_session(
    container: Annotated[
        ApplicationContainer,
        Depends(get_container),
    ],
) -> ChatSessionCreatedResponse:
    session_id = container.chat_service.create_session()
    return ChatSessionCreatedResponse(
        session_id=session_id
    )

Для создания новой сессии клиенту не нужно отправлять JSON. Все необходимые данные сервер формирует самостоятельно: ChatService создаёт пустой ChatSession, генерирует уникальный session_id и сохраняет сессию в своём хранилище.

В ответ FastAPI возвращает статус 201 Created. Он означает, что запрос не просто успешно обработан и на сервере появился новый ресурс, то есть новая чат-сессия. А в теле ответа клиент получает session_id. Напомню, уже интерфейс должен сохранить его и подставлять в адреса следующих запросов: при отправке сообщения, получении истории, очистке или удалении диалога. Сам пользователь обычно этого идентификатора не видит.

Ниже добавьте чтение состояния:

@router.get(
    "/chat/sessions/{session_id}",
    responses={
        404: {
            "model": ErrorResponse,
            "description": "Чат-сессия не найдена.",
        },
    },
)
def get_chat_session(
    session_id: str,
    container: Annotated[
        ApplicationContainer,
        Depends(get_container),
    ],
) -> ChatSessionResponse:
    try:
        snapshot = container.chat_service.get_session(
            session_id
        )
    except ChatSessionNotFoundError as error:
        raise ApiError(
            status_code=status.HTTP_404_NOT_FOUND,
            code="chat_session_not_found",
            message="Чат-сессия не найдена.",
        ) from error

    return ChatSessionResponse(
        session_id=snapshot.session_id,
        messages=[
            ChatHistoryMessageResponse(
                role=message.role,
                content=message.content,
            )
            for message in snapshot.messages
        ],
        history_messages=snapshot.history_messages,
        has_summary=snapshot.has_summary,
        pending_confirmation=(
            snapshot.pending_confirmation
        ),
    )

Часть адреса в фигурных скобках называется параметром пути. Например при обращении к /chat/sessions/abc FastAPI передаст строку abc в параметр session_id.

Затем эндпоинт вызовет ChatService.get_session(). Пока базы данных у нас нет, поэтому сервис ищет идентификатор в собственном словаре _sessions, который хранится в памяти процесса. Именно в этот словарь метод create_session() ранее добавил новый ChatSession.

Если идентификатор отсутствует в словаре, _require_session() выбрасывает ChatSessionNotFoundError, а эндпоинт преобразует её в ответ 404 Not Found. Если сессия найдена, сервис возвращает её безопасное состояние, а эндпоинт формирует список ChatHistoryMessageResponse.

Создание сессии и чтение её состояния не обращаются к модели и не расходуют токены ProxyAPI. Эти операции работают только с объектами, находящимися в памяти нашего приложения. После перезапуска сервера словарь очистится, и все созданные сессии исчезнут. Ограничения такого учебного хранения мы подробнее разберём в конце раздела.

На практике клиент использует эти два запроса в разные моменты. POST /chat/sessions выполняется, когда пользователь нажимает «Новый чат». Полученный идентификатор можно сохранить в состоянии страницы и добавить в её адрес, например /chats/b4fb.... Способ хранения на стороне клиента мы сейчас не реализуем, но наш сервер уже предоставляет всё необходимое.

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

Шаг 3

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

Сейчас в ChatService есть reset_session(). Он очищает историю, но не сообщает об отсутствующей сессии и ничего не удаляет из словаря. А для полноценного API нам нужны еще две операции с разным смыслом:

И начнём с очистки текущей сессии. Этот сценарий нужен, когда пользователь хочет удалить прежнюю переписку и начать разговор без старого контекста, но продолжить использовать тот же session_id. Для этого доработаем уже существующий метод reset_session() и дадим ему более понятное название clear_session().

Откройте app/services/chat_service.py. Найдите метод reset_session(), переименуйте его в clear_session() и полностью замените код в нем:

def clear_session(self, session_id: str) -> None:
    session_key = self._normalize_session_id(session_id)
    session = self._require_session(session_key)

    with session.lock:
        confirmation_id = session.pending_confirmation_id
        session.pending_confirmation_id = None
        session.messages.clear()
        session.summary = None

    if confirmation_id is not None:
        try:
            self._tool_loop.cancel(confirmation_id)
        except ToolLoopError:
            pass

Сначала метод очищает session_id от лишних пробелов и с помощью _require_session() находит сессию в словаре. Если такого идентификатора нет, возникает ChatSessionNotFoundError. Благодаря этому HTTP-клиент сможет отличить успешную очистку от обращения к несуществующему диалогу.

Все изменения выполняются внутри session.lock, поэтому другой запрос этой же сессии не сможет одновременно изменить историю. Метод удаляет сообщения, резюме и идентификатор ожидающего действия. Если действие было подготовлено, оно также отменяется в ToolExecutor. В результате сессия остаётся в словаре под прежним session_id, но больше не содержит старого контекста.

Сам объект ChatSession остаётся внутри self._sessions. Поэтому после clear_session() клиент может отправить новое сообщение с тем же идентификатором:

Теперь рассмотрим вторую операцию, а именно полное удаление сессии. В отличие от очистки, здесь недостаточно удалить сообщения и резюме, ведь объект ChatSession также должен исчезнуть из словаря _sessions. После этого прежний session_id больше нельзя будет использовать для отправки сообщений или получения истории.

После clear_session() добавьте:

def delete_session(self, session_id: str) -> None:
    session_key = self._normalize_session_id(session_id)

    with self._sessions_lock:
        session = self._sessions.pop(session_key, None)

    if session is None:
        raise ChatSessionNotFoundError(
            "Чат-сессия не найдена."
        )

    with session.lock:
        confirmation_id = session.pending_confirmation_id
        session.pending_confirmation_id = None
        session.messages.clear()
        session.summary = None

    if confirmation_id is not None:
        try:
            self._tool_loop.cancel(confirmation_id)
        except ToolLoopError:
            pass

Метод pop() одновременно ищет объект в словаре и удаляет найденную запись. Операция выполняется под _sessions_lock, потому что словарь является общим для всех диалогов. Если идентификатора нет, pop() возвращает None, после чего сервис выбрасывает ChatSessionNotFoundError.

Найденный объект дополнительно очищается под его собственной блокировкой. Если сессия ожидала подтверждения, подготовленное действие отменяется так же, как при очистке. Главное отличие состоит в том, что запись уже удалена из _sessions. Следующий вызов get_session() или отправка сообщения с прежним идентификатором не найдёт диалог.

Теперь у ChatService есть все необходимые операции: создание, чтение, очистка и удаление. Поэтому можно целиком обновить консоль. Откройте app/console.py и удалите импорт:

from uuid import uuid4

Найдите функцию run_console(). В её начале замените самостоятельное создание идентификатора:

session_id = uuid4().hex

на вызов сервиса:

session_id = service.create_session()

Теперь консоль не только получает строку, но и сразу создаёт соответствующий ChatSession внутри сервиса. Первое сообщение будет отправлено в уже существующий диалог.

В той же функции найдите обработку команды /new и полностью замените ветку:

if command == "/new":
    service.delete_session(session_id)
    session_id = service.create_session()
    print(f"Новый Session ID: {session_id}")
    continue

При запуске консоли метод create_session() создаёт первую сессию, а run_console() сохраняет полученный идентификатор в переменной session_id. Все сообщения пользователя отправляются в этот диалог.

Когда пользователь вводит команду /new, сервис удаляет текущую сессию вместе с её историей и ожидающим действием. Затем create_session() создаёт новую пустую сессию, а переменная session_id получает другой идентификатор. После этого все следующие сообщения относятся уже к новому диалогу.

После этого шага и консоль, и будущий HTTP API получают идентификаторы только через ChatService.create_session(). Однако обработка сообщений всё ещё использует прежний _get_or_create_session(). В следующем шаге мы заменим его обязательным поиском, обновим адрес отправки сообщения и подключим очистку и удаление к FastAPI.

Шаг 4

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

В прошлом разделе клиент обращался к одному адресу POST /chat/reply и передавал идентификатор в JSON. Если строки не было в словаре, _get_or_create_session() молча создавал новый объект. Теперь клиент сначала получает идентификатор через POST /chat/sessions, поэтому отправка сообщения должна требовать уже существующую сессию.

Было раньше:

POST /chat/reply
{
  "session_id": "строка от клиента",
  "message": "Где мой заказ?"
}

Будет теперь:

POST /chat/sessions/{session_id}/messages
{
  "message": "Где мой заказ?"
}

Получив такой запрос, ChatService сначала должен найти указанную сессию. Если session_id неизвестен, сервис больше не создаёт новый диалог автоматически. Вместо этого HTTP-клиент получит 404 Not Found и сможет предложить пользователю открыть новую сессию.

Если сессия найдена, сервис проверяет её текущее состояние. Иногда после ответа модели внутри диалога остаётся подготовленное действие, которое изменит данные, например создание заявки на возврат. Пока пользователь не подтвердил и не отменил эту операцию, принимать следующее обычное сообщение нельзя: иначе показанные аргументы действия и дальнейшая история могут смешаться.

В такой ситуации запрос сам по себе составлен правильно и сессия существует, но продолжить разговор прямо сейчас невозможно. Поэтому сервер вернёт 409 Conflict. Этот статус сообщает клиенту, что сначала нужно завершить уже начатое действие — подтвердить его или отменить. Чтобы различать такую ситуацию внутри сервиса, добавим отдельную ошибку ChatSessionConflictError.

Откройте app/services/chat_service.py и после ChatSessionNotFoundError добавьте:

class ChatSessionConflictError(ChatServiceError):
    pass

В методе stream_reply() замените:

session = self._get_or_create_session(session_key)

на:

session = self._require_session(session_key)

После этой строки метод больше не создаёт сессию. Он может работать только с идентификатором, который ранее зарегистрировал create_session().

Сразу ниже замените проверку ожидающего действия:

if session.pending_confirmation_id is not None:
    raise ChatSessionConflictError(
        "Сначала подтвердите или отмените "
        "подготовленное действие."
    )

Такую же замену _get_or_create_session() на _require_session() выполните в confirm_pending() и cancel_pending(). Ошибки об отсутствии подготовленного действия замените на ChatSessionConflictError:

raise ChatSessionConflictError(
    "В этой сессии нет действия для подтверждения."
)

и:

raise ChatSessionConflictError(
    "В этой сессии нет действия для отмены."
)

После замены всех вызовов метод _get_or_create_session() больше нигде не используется, поэтому удалите его из ChatService. Раньше он одновременно искал сессию и создавал новую, если идентификатор не был найден. Теперь эти действия разделены, где create_session() явно создаёт новый диалог, а _require_session() только ищет уже существующий и сообщает об ошибке, если его нет. Благодаря этому новые сессии больше не появляются случайно из-за неправильного session_id.

Теперь создание и поиск сессий работают явно, а случайное появление нового диалога исключено. Но клиенту недостаточно знать только то, что сессия существует. Ему также нужно понимать, в каком состоянии находится текущий разговор.

После обычного ответа пользователь может сразу отправить следующее сообщение. Если же модель подготовила действие, например создание заявки на возврат, сначала нужно показать пользователю кнопки подтверждения и отмены. Поэтому следующим шагом добавим в ChatReply после tool_names отдельное поле, которое сообщит клиенту, можно ли продолжать диалог или требуется решение пользователя:

requires_confirmation: bool

При создании ChatReply внутри stream_reply() передайте:

requires_confirmation=(loop_result.confirmation is not None),

Обычный текстовый ответ вернёт false. Подготовленное изменяющее действие вернёт true, после чего клиент сможет показать кнопки подтверждения и отмены.

Откройте app/services/tool_loop.py. В методе _build_confirmation_text() замените консольные подсказки нейтральным сообщением:

return (
    "Подготовлено действие, которое изменяет данные.\n"
    f"Инструмент: {request.tool_name}\n"
    f"Аргументы: {request.arguments_json}\n"
    "Действие ожидает подтверждения пользователя."
)

Сам сервис больше не решает, каким способом пользователь подтвердит или отменит действие. Он только сообщает, что решение необходимо. Консоль в такой ситуации покажет команды, а клиентский интерфейс сможет отобразить кнопки.

Внутреннюю логику ChatService мы подготовили. Теперь нужно изменить HTTP-контракт отправки сообщения: session_id будет приходить из адреса, поэтому дублировать его в JSON больше не требуется.

Откройте app/api/schemas/chat.py и удалите поле session_id из ChatReplyRequest. В модели останется только новая реплика пользователя:

class ChatReplyRequest(BaseModel):
    model_config = ConfigDict(
        extra="forbid",
        str_strip_whitespace=True,
    )

    message: str = Field(min_length=1, max_length=10_000)

В ChatReplyResponse после tool_names добавьте:

requires_confirmation: bool

Перейдите в app/api/routes/chat.py и дополните импорт ошибок:

from app.services.chat_service import (
    ChatServiceError,
    ChatSessionConflictError,
    ChatSessionNotFoundError,
)

Полностью замените прежний reply_to_chat() вместе с декоратором:

@router.post(
    "/chat/sessions/{session_id}/messages",
    responses={
        404: {
            "model": ErrorResponse,
            "description": "Чат-сессия не найдена.",
        },
        409: {
            "model": ErrorResponse,
            "description": (
                "Состояние сессии не позволяет "
                "отправить сообщение."
            ),
        },
        422: {
            "model": ErrorResponse,
            "description": "Запрос не прошёл проверку.",
        },
        502: {
            "model": ErrorResponse,
            "description": "Ответ чата не получен.",
        },
    },
)
def reply_to_chat(
    session_id: str,
    payload: ChatReplyRequest,
    container: Annotated[
        ApplicationContainer,
        Depends(get_container),
    ],
) -> ChatReplyResponse:
    """Добавляет сообщение в существующую сессию."""
    try:
        result = container.chat_service.reply(
            session_id=session_id,
            user_text=payload.message,
        )
    except ChatSessionNotFoundError as error:
        raise ApiError(
            status_code=status.HTTP_404_NOT_FOUND,
            code="chat_session_not_found",
            message="Чат-сессия не найдена.",
        ) from error
    except ChatSessionConflictError as error:
        raise ApiError(
            status_code=status.HTTP_409_CONFLICT,
            code="chat_session_conflict",
            message=str(error),
        ) from error
    except ChatServiceError as error:
        raise ApiError(
            status_code=status.HTTP_502_BAD_GATEWAY,
            code="chat_reply_failed",
            message="Не удалось получить ответ чата.",
        ) from error

    return ChatReplyResponse(
        text=result.text,
        session_id=result.session_id,
        history_messages=result.history_messages,
        model=result.model,
        prompt_id=result.prompt_id,
        prompt_version=result.prompt_version,
        elapsed_seconds=result.elapsed_seconds,
        usage=TokenUsageResponse(
            prompt_tokens=result.prompt_tokens,
            completion_tokens=result.completion_tokens,
            total_tokens=result.total_tokens,
        ),
        tool_calls_count=result.tool_calls_count,
        tool_names=list(result.tool_names),
        requires_confirmation=(
            result.requires_confirmation
        ),
        summary_updated=result.summary_updated,
        has_summary=result.has_summary,
    )

Идентификатор теперь приходит из пути, а сообщение — из JSON. Эндпоинт различает отсутствие сессии, конфликт состояния и ошибку AI-сценария. Pydantic по-прежнему возвращает 422 до запуска функции, если тело запроса не соответствует контракту.

На стороне клиента пользователь нажимает «Отправить», после чего интерфейс подставляет сохранённый session_id в адрес и временно блокирует повторное нажатие. После ответа текст ассистента добавляется на экран, а значение requires_confirmation определяет, нужно ли снова открыть ввод или показать выбор действия.

Отправка сообщений теперь работает только с заранее созданными сессиями. Осталось предоставить HTTP-клиенту доступ к двум другим методам ChatService, которые мы подготовили в предыдущей лекции: clear_session() и delete_session().

Для успешного выполнения этих операций будем возвращать ответ без JSON-тела со статусом 204 No Content. Поэтому в том же файле дополните импорт FastAPI классом Response:

from fastapi import APIRouter, Depends, Response, status

После маршрута сообщения добавьте очистку истории:

@router.delete(
    "/chat/sessions/{session_id}/messages",
    status_code=status.HTTP_204_NO_CONTENT,
    responses={
        404: {
            "model": ErrorResponse,
            "description": "Чат-сессия не найдена.",
        },
    },
)
def clear_chat_session(
    session_id: str,
    container: Annotated[
        ApplicationContainer,
        Depends(get_container),
    ],
) -> Response:
    try:
        container.chat_service.clear_session(session_id)
    except ChatSessionNotFoundError as error:
        raise ApiError(
            status_code=status.HTTP_404_NOT_FOUND,
            code="chat_session_not_found",
            message="Чат-сессия не найдена.",
        ) from error

    return Response(
        status_code=status.HTTP_204_NO_CONTENT
    )

Адрес заканчивается на /messages, поэтому удаляется содержимое диалога, а сам session_id сохраняется. Статус 204 No Content сообщает об успехе без JSON-тела.

Ниже добавьте полное удаление:

@router.delete(
    "/chat/sessions/{session_id}",
    status_code=status.HTTP_204_NO_CONTENT,
    responses={
        404: {
            "model": ErrorResponse,
            "description": "Чат-сессия не найдена.",
        },
    },
)
def delete_chat_session(
    session_id: str,
    container: Annotated[
        ApplicationContainer,
        Depends(get_container),
    ],
) -> Response:
    try:
        container.chat_service.delete_session(session_id)
    except ChatSessionNotFoundError as error:
        raise ApiError(
            status_code=status.HTTP_404_NOT_FOUND,
            code="chat_session_not_found",
            message="Чат-сессия не найдена.",
        ) from error

    return Response(
        status_code=status.HTTP_204_NO_CONTENT
    )

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

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

Эта проблема решается двумя блокировками, которые уже существуют в app/services/chat_service.py:

  • _sessions_lock принадлежит классу ChatService и защищает общий словарь self._sessions при создании, поиске и удалении сессий
  • session.lock принадлежит отдельному объекту ChatSession и защищает сообщения, резюме и ожидающее действие одного конкретного диалога

_sessions_lock создаётся один раз в конструкторе ChatService:

self._sessions: dict[str, ChatSession] = {}
self._sessions_lock = Lock()

А каждый новый ChatSession получает собственную блокировку:

@dataclass
class ChatSession:
    messages: list[Message] = field(default_factory=list)
    summary: str | None = None
    lock: Lock = field(default_factory=Lock)
    pending_confirmation_id: str | None = None

Благодаря этому два запроса одной сессии выполняются последовательно и не перемешивают историю. При этом разные сессии используют разные объекты session.lock, поэтому пользователи могут работать со своими диалогами независимо друг от друга.

сессия A: запрос 1 → lock A → ответ → сохранить историю
сессия A: запрос 2 → ждёт lock A → получает обновлённую историю

сессия B: запрос 1 → lock B → выполняется независимо от A

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

На этом этапе проект уже можно запустить и убедиться, что основной жизненный цикл сессии работает. Выполните из корня проекта:

fastapi dev app/api/main.py

Затем откройте http://127.0.0.1:8000/docs. В группе chat должны появиться операции создания сессии, получения истории, отправки сообщения, очистки и полного удаления диалога.

Пока достаточно убедиться, что сервер запускается и новые маршруты отображаются в Swagger UI. При желании можно создать пустую сессию через POST /chat/sessions, скопировать полученный session_id и проверить её состояние через GET /chat/sessions/{session_id}. Эти две операции пока не обращаются к ProxyAPI и не расходуют токены.

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

Шаг 5

В третьем модуле мы добавили инструмент create_return_request, который создаёт заявку на возврат и тем самым изменяет данные приложения. Такие операции нельзя выполнять сразу после решения модели: модель может неправильно понять пользователя или выбрать неверные аргументы.

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

В консольном приложении пользователь принимал решение с помощью команд /confirm и /cancel. Теперь нужно предоставить такую же возможность HTTP-клиенту через отдельные запросы FastAPI.

На стороне клиента это может выглядеть как отдельная карточка или диалоговое окно. В нём показываются название подготовленной операции и проверенные аргументы, например номер заказа и причина возврата. Рядом находятся кнопки «Подтвердить» и «Отменить». Пока пользователь не выбрал вариант, поле ввода обычных сообщений остаётся недоступным.

Нажатие кнопки не отправляет модели новый текст. Клиент вызывает один из двух HTTP-адресов, а сервер находит действие, которое уже сохранено внутри этой сессии:

кнопка «Подтвердить»
→ POST /chat/sessions/{id}/confirm
→ выполнение сохранённой операции
→ показать результат

кнопка «Отменить»
→ POST /chat/sessions/{id}/cancel
→ удалить сохранённую операцию
→ снова открыть обычный ввод

Клиент не будет повторно передавать имя инструмента и его аргументы. Эти значения уже проверены и сохранены внутри ToolExecutor. Для принятия решения достаточно идентификатора сессии. Такой подход не позволяет подменить номер заказа или причину возврата между показом действия и его выполнением.

Теперь подготовим формат ответа, который получит HTTP-клиент после подтверждения или отмены действия. Методы confirm_pending() и cancel_pending() уже возвращают объект ToolActionReply с текстом результата и технической записью о выполненной операции. Однако передавать внутренний объект сервиса напрямую через API не стоит. Поэтому создадим отдельную Pydantic-модель и включим в неё только те данные, которые действительно нужны клиенту.

Откройте app/api/schemas/chat.py и после ChatReplyResponse добавьте:

class ToolActionResponse(BaseModel):
    text: str
    tool_name: str
    status: str
    error_code: str | None

В этой модели поле text содержит сообщение, которое можно показать пользователю. В tool_name передаётся название рассматриваемой операции, а status сообщает результат её обработки, например success при успешном выполнении или cancelled при отмене. Поле error_code остаётся пустым при обычном завершении и заполняется только в случае ожидаемой ошибки, которую клиент может обработать отдельно.

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

В app/api/routes/chat.py добавьте ToolActionResponse в существующий импорт моделей из app.api.schemas.chat:

from app.api.schemas.chat import (
    ChatHistoryMessageResponse,
    ChatReplyRequest,
    ChatReplyResponse,
    ChatSessionCreatedResponse,
    ChatSessionResponse,
    ToolActionResponse,
)

После маршрутов удаления добавьте подтверждение:

@router.post(
    "/chat/sessions/{session_id}/confirm",
    responses={
        404: {
            "model": ErrorResponse,
            "description": "Чат-сессия не найдена.",
        },
        409: {
            "model": ErrorResponse,
            "description": (
                "В сессии нет действия "
                "для подтверждения."
            ),
        },
        502: {
            "model": ErrorResponse,
            "description": "Действие не выполнено.",
        },
    },
)
def confirm_chat_action(
    session_id: str,
    container: Annotated[
        ApplicationContainer,
        Depends(get_container),
    ],
) -> ToolActionResponse:
    try:
        result = container.chat_service.confirm_pending(
            session_id
        )
    except ChatSessionNotFoundError as error:
        raise ApiError(
            status_code=status.HTTP_404_NOT_FOUND,
            code="chat_session_not_found",
            message="Чат-сессия не найдена.",
        ) from error
    except ChatSessionConflictError as error:
        raise ApiError(
            status_code=status.HTTP_409_CONFLICT,
            code="chat_session_conflict",
            message=str(error),
        ) from error
    except ChatServiceError as error:
        raise ApiError(
            status_code=status.HTTP_502_BAD_GATEWAY,
            code="tool_confirmation_failed",
            message=(
                "Не удалось выполнить "
                "подтверждённое действие."
            ),
        ) from error

    return ToolActionResponse(
        text=result.text,
        tool_name=result.audit.tool_name,
        status=result.audit.status,
        error_code=result.audit.error_code,
    )

Этот эндпоинт подтверждает действие, которое ранее было подготовлено моделью и сохранено в указанной сессии. Он находит ожидающую операцию, передаёт её на выполнение и возвращает клиенту результат. Например после подтверждения заявки на возврат эндпоинт действительно запускает функцию её создания. Повторно использовать то же подтверждение нельзя и сохранённое действие извлекается только один раз.

Тело запроса здесь не требуется, потому что вся информация о подготовленном действии уже связана с сессией. По session_id метод ChatService находит нужную сессию и получает из неё pending_confirmation_id — идентификатор операции, которая ожидает решения пользователя. Затем ToolExecutor извлекает ранее сохранённое действие и выполняет его только один раз. Модель не вызывается повторно и не формирует новые аргументы: запускается именно тот инструмент с теми проверенными значениями, которые пользователь видел перед подтверждением.

Если сессия с указанным session_id не найдена, сервер вернёт 404 Not Found. Если сессия существует, но в ней нет действия, ожидающего подтверждения, клиент получит 409 Conflict. Ошибка при выполнении инструмента преобразуется в 502 Bad Gateway. При успешном подтверждении эндпоинт вернёт 200 OK и объект ToolActionResponse с результатом операции.

Ниже добавьте отмену:

@router.post(
    "/chat/sessions/{session_id}/cancel",
    responses={
        404: {
            "model": ErrorResponse,
            "description": "Чат-сессия не найдена.",
        },
        409: {
            "model": ErrorResponse,
            "description": (
                "В сессии нет действия для отмены."
            ),
        },
        502: {
            "model": ErrorResponse,
            "description": "Действие не отменено.",
        },
    },
)
def cancel_chat_action(
    session_id: str,
    container: Annotated[
        ApplicationContainer,
        Depends(get_container),
    ],
) -> ToolActionResponse:
    try:
        result = container.chat_service.cancel_pending(
            session_id
        )
    except ChatSessionNotFoundError as error:
        raise ApiError(
            status_code=status.HTTP_404_NOT_FOUND,
            code="chat_session_not_found",
            message="Чат-сессия не найдена.",
        ) from error
    except ChatSessionConflictError as error:
        raise ApiError(
            status_code=status.HTTP_409_CONFLICT,
            code="chat_session_conflict",
            message=str(error),
        ) from error
    except ChatServiceError as error:
        raise ApiError(
            status_code=status.HTTP_502_BAD_GATEWAY,
            code="tool_cancellation_failed",
            message=(
                "Не удалось отменить "
                "подготовленное действие."
            ),
        ) from error

    return ToolActionResponse(
        text=result.text,
        tool_name=result.audit.tool_name,
        status=result.audit.status,
        error_code=result.audit.error_code,
    )

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

Подтверждение и отмена работают только один раз. Когда пользователь вызывает confirm_pending() или cancel_pending(), ChatService очищает поле pending_confirmation_id. Это означает, что в сессии больше нет действия, ожидающего решения.

Поэтому после успешного подтверждения нельзя повторно выполнить ту же операцию, а после отмены — подтвердить уже удалённое действие. Повторный запрос вернёт 409 Conflict и не приведёт ни к каким дополнительным изменениям.

После принятия решения ChatService сохраняет его результат в истории диалога. При следующем запросе GET /chat/sessions/{session_id} клиент увидит обновлённую историю, а поле pending_confirmation будет равно false. С этого момента пользователь снова может отправлять обычные сообщения в эту сессию.

И вот теперь можно сказать, что мы завершили внедрение Chat API. Сервер умеет создавать сессии, принимать сообщения, возвращать историю, очищать и удалять диалоги, а также безопасно подтверждать или отменять подготовленные операции, изменяющие данные. В следующем шаге мы запустим приложение и последовательно проверим все эти возможности через Swagger UI.

Шаг 6

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

Теперь запустим проект и проверим его работу вручную. Запустите сервер из корня проекта:

fastapi dev app/api/main.py

Откройте http://127.0.0.1:8000/docs. В группе chat появился полный набор операций:

Создаём сессию и отправляем сообщения

Начните с POST /chat/sessions. У этого запроса нет тела. Нажмите Try it out, затем Execute.

Сервер должен вернуть статус 201 Created и новый идентификатор:

Скопируйте полученное значение. Во всех дальнейших примерах вместо {session_id} нужно подставлять именно его. Если оставить фигурные скобки или допустить опечатку, сервер не найдёт сессию и вернёт 404.

Откройте GET /chat/sessions/{session_id}, вставьте идентификатор в поле пути и выполните запрос. Новая сессия пока пуста:

Этот запрос не обращается к ProxyAPI и не расходует токены, он только читает текущее состояние из памяти ChatService.

Теперь откройте POST /chat/sessions/{session_id}/messages, укажите тот же идентификатор и отправьте:

{
  "message": "Монитор пришёл с треснутой подставкой."
}

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

Ответ содержит текст модели и знакомые метаданные. Новое поле requires_confirmation для обычного ответа должно быть равно false.

Отправьте в эту же сессию второе сообщение:

{
  "message": "Номер заказа 7718."
}

Модель получит предыдущую реплику и поймёт, к какой проблеме относится номер.

Затем снова выполните GET /chat/sessions/{session_id}. В messages должны находиться четыре элемента в порядке:

В публичной истории нет тегов <customer_message>, developer-инструкции и технических сообщений инструментов. Клиент получает только реплики, необходимые для отображения разговора.

Если история однажды превысит бюджет и будет сокращена, ранние сообщения перейдут во внутреннее резюме. Тогда has_summary станет равен true, но само резюме не появится в JSON. Поэтому этот маршрут возвращает текущую точную часть истории, а не обязательно каждую реплику за всё время существования очень длинного диалога.

Проверяем изоляцию и жизненный цикл

Ещё раз выполните POST /chat/sessions и сохраните второй идентификатор. Отправьте во вторую сессию другое обращение, например:

{
  "message": "Кофемашина не включается. Заказ 8041."
}

Получите состояние обеих сессий через отдельные запросы GET. В первой должны находиться сообщения о мониторе и заказе 7718, во второй — только обращение о кофемашине и заказе 8041. Так мы проверяем изоляцию не по формулировке ответа модели, а по фактической истории, которую хранит приложение.

Теперь очистите первую сессию через DELETE /chat/sessions/{session_id}/messages. Успешный ответ имеет статус 204 No Content и пустое тело.

Повторный GET по тому же идентификатору должен вернуть существующую сессию с пустым списком сообщений.

После этого удалите её полностью через DELETE /chat/sessions/{session_id}. Снова выполните GET. Теперь сервер вернёт 404 Not Found и наш единый контракт ошибки:

Обратите внимание на новый Request ID в каждом ответе. session_id остаётся одинаковым у нескольких операций одного диалога, а Request ID создаётся заново для каждого HTTP-запроса.

Проверяем подтверждение действия

Создайте ещё одну сессию и попросите выполнить операцию, изменяющую данные:

{
  "message": "Создайте заявку на возврат заказа 7718. Товар пришёл повреждённым."
}

Если модель выберет create_return_request, ответ сообщит о подготовленном действии, а поле requires_confirmation станет равно true.

А получение состояния сессии покажет pending_confirmation: true.

Выбор инструмента выполняет модель, поэтому на простом или неоднозначном запросе она может сначала задать уточняющий вопрос. Это не ошибка API. Ответьте на вопрос и повторите просьбу, указав номер заказа и причину возврата.

Пока действие ожидает решения, попробуйте отправить обычное сообщение в ту же сессию. Сервер вернёт 409 Conflict. Так история не изменится между показом аргументов и решением пользователя.

Для выполнения вызовите:

POST /chat/sessions/{session_id}/confirm

Тело запроса не требуется. При успехе ответ будет похож на следующий:

Повторите тот же запрос подтверждения. Теперь ожидающего действия нет, поэтому сервер вернёт 409. Это наглядно показывает одноразовость операции.

Для проверки отмены подготовьте действие ещё раз и вместо подтверждения вызовите:

POST /chat/sessions/{session_id}/cancel

В ответе статус будет равен cancelled, а функция создания заявки не запустится. Повторная отмена также завершится статусом 409.

После этого вызовите:

GET /chat/sessions/{session_id}

Убедитесь, что в истории отражена отмена подготовленного действия, а поле pending_confirmation содержит false.

Это означает, что сессия больше не ожидает решения пользователя и снова готова принимать обычные сообщения. Затем повторно вызовите эндпоинт отмены: сервер вернёт 409 Conflict, поскольку отменять уже нечего.

Ограничение хранения в памяти

Наши сессии всё ещё находятся в словаре ChatService._sessions. Это нормальное учебное решение: оно позволило сосредоточиться на истории, блокировках и HTTP-контрактах без подключения базы данных.

Но у такого хранилища есть важные ограничения. После перезапуска процесса все диалоги исчезнут. Кроме того, у нескольких процессов сервера будут разные словари. Запрос на создание может попасть в один процесс, а следующее сообщение — в другой, который ничего не знает об идентификаторе.

Поэтому текущий Chat API следует запускать одним процессом. В реальном приложении состояние переносят в общее постоянное хранилище, а блокировку сессии заменяют механизмом, работающим между процессами. При этом маршруты уже не обращаются к словарю напрямую: они используют публичные методы ChatService. Это позволит позднее изменить внутреннее хранение, не переписывая HTTP-контракт.

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

Шаг 7

Кто должен создавать session_id в полноценном Chat API?

Правильный ответ
  • ✅ Сервер при вызове POST /chat/sessions

Шаг 8

Какой результат ожидается при запросе неизвестной чат-сессии?

Правильный ответ
  • ✅ HTTP 404 с кодом chat_session_not_found

Шаг 9

Какие действия должны выполняться при очистке существующей сессии?

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

Правильный ответ
  • ✅ Удалить сообщения истории
  • ✅ Удалить сохранённое резюме
  • ✅ Отменить ожидающее подтверждения действие, если оно есть

Шаг 10

В сессии уже ожидает подтверждения изменяющее действие. Клиент отправляет новое сообщение. Как должен ответить API?

Правильный ответ
  • ✅ HTTP 409, потому что состояние сессии конфликтует с новой операцией

Шаг 11

Создание чат-сессии

В заготовке уже предоставлены маршрутизатор router и зависимость get_chat_service. Вам нужно добавить функцию-эндпоинт POST /chat/sessions, которая попросит сервис создать новую сессию и вернёт клиенту её session_id.

Задача

Добавьте функцию-эндпоинт create_chat_session и зарегистрируйте её для POST /chat/sessions. Внутри эндпоинта получите сервис через зависимость, создайте одну сессию и верните её идентификатор со статусом 201.

Требования

  • Не изменяйте имя функции create_chat_session.
  • Используйте декоратор @router.post("/chat/sessions", status_code=201).
  • Параметр service должен получать значение через Depends(get_chat_service).
  • Вызовите service.create_session() ровно один раз.
  • Верните словарь только с ключом "session_id". Значением должно быть то, что вернул service.create_session().
  • Не создавайте UUID и не формируйте идентификатор внутри эндпоинта.
  • Успешный HTTP-ответ должен иметь статус 201.

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

Клиент отправляет запрос без тела:

POST /chat/sessions

Если сервис вернул строку "abc123", HTTP-ответ выглядит так:

201 Created
Content-Type: application/json

{"session_id":"abc123"}
Правильный ответ
from fastapi import Depends

from task_environment import get_chat_service, router

@router.post("/chat/sessions", status_code=201)
async def create_chat_session(service=Depends(get_chat_service)):
    session_id = service.create_session()

    return {"session_id": session_id}

Шаг 12

Получение состояния чат-сессии

В заготовке уже объявлена функция-эндпоинт GET /chat/sessions/{session_id} для получения состояния диалога. Вам нужно запросить снимок сессии у сервиса, преобразовать его в публичный JSON и вернуть ошибку 404, если сессия не найдена.

Задача

Дополните функцию-эндпоинт get_chat_session, зарегистрированную для GET /chat/sessions/{session_id}. Получите снимок у сервиса, соберите ответ только из разрешённых полей и преобразуйте отсутствие сессии в ApiError со статусом 404.

Требования

  • Сохраните имя функции, путь маршрута и параметры из заготовки.
  • Вызовите service.get_session(session_id) ровно один раз.
  • Если сервис выбросил SessionNotFoundError, выбросьте ApiError(404, "chat_session_not_found", "Чат-сессия не найдена.").
  • При успехе верните словарь ровно с ключами "session_id", "messages", "history_messages", "has_summary" и "pending_confirmation".
  • Значения этих полей возьмите из одноимённых атрибутов объекта snapshot.
  • Каждый элемент snapshot.messages преобразуйте в словарь ровно с ключами "role" и "content".
  • Не возвращайте внутренние объекты сессии и идентификатор подготовленного действия.

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

Клиент запрашивает существующую сессию:

GET /chat/sessions/abc123

Сервер возвращает её публичное состояние:

200 OK
Content-Type: application/json

{
  "session_id": "abc123",
  "messages": [
    {"role": "user", "content": "Привет"}
  ],
  "history_messages": 1,
  "has_summary": false,
  "pending_confirmation": false
}

Для неизвестного идентификатора эндпоинт должен вернуть статус 404.

Правильный ответ
from fastapi import Depends

from task_environment import ApiError, SessionNotFoundError, get_chat_service, router

@router.get("/chat/sessions/{session_id}")
def get_chat_session(session_id: str, service=Depends(get_chat_service)):
    try:
        snapshot = service.get_session(session_id)
    except SessionNotFoundError:
        raise ApiError(
            404,
            "chat_session_not_found",
            "Чат-сессия не найдена.",
        )

    return {
        "session_id": snapshot.session_id,
        "messages": [
            {
                "role": message.role,
                "content": message.content,
            }
            for message in snapshot.messages
        ],
        "history_messages": snapshot.history_messages,
        "has_summary": snapshot.has_summary,
        "pending_confirmation": snapshot.pending_confirmation,
    }

Шаг 13

Сообщение в существующей сессии

В заготовке уже объявлена функция-эндпоинт POST /chat/sessions/{session_id}/messages, которая получает идентификатор сессии и проверенное сообщение пользователя. Вам нужно передать эти данные чат-сервису, вернуть его ответ и обработать ошибки со статусами 404 и 409.

Задача

Дополните функцию-эндпоинт send_chat_message, зарегистрированную для POST /chat/sessions/{session_id}/messages. Вызовите service.reply(...), соберите публичный JSON-ответ и преобразуйте ожидаемые ошибки сервиса в статусы 404 и 409.

Требования

  • Сохраните имя функции, путь маршрута и параметры из заготовки.
  • Вызовите service.reply(session_id=session_id, user_text=payload.message) ровно один раз.
  • Если сервис выбросил SessionNotFoundError, выбросьте ApiError(404, "chat_session_not_found", "Чат-сессия не найдена.").
  • Если сервис выбросил SessionConflictError, выбросьте ApiError(409, "chat_session_conflict", "Сначала подтвердите или отмените подготовленное действие.").
  • Не перехватывайте другие исключения.
  • При успехе верните словарь ровно с ключами "text", "session_id" и "requires_confirmation".
  • Значения ответа возьмите из одноимённых атрибутов результата service.reply(...).

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

Клиент отправляет сообщение в существующую сессию:

POST /chat/sessions/abc123/messages
Content-Type: application/json

{"message":"Привет"}

Если сервис успешно подготовил ответ, клиент получает:

200 OK
Content-Type: application/json

{
  "text": "Здравствуйте!",
  "session_id": "abc123",
  "requires_confirmation": false
}

Неизвестная сессия должна привести к статусу 404, а попытка отправить сообщение при ожидающем подтверждения действии — к статусу 409.

Правильный ответ
from fastapi import Depends

from task_environment import (
    ApiError,
    ChatMessageRequest,
    SessionConflictError,
    SessionNotFoundError,
    get_chat_service,
    router,
)

@router.post("/chat/sessions/{session_id}/messages")
def send_chat_message(session_id: str, payload: ChatMessageRequest, service=Depends(get_chat_service)):
    try:
        result = service.reply(
            session_id=session_id,
            user_text=payload.message,
        )
    except SessionNotFoundError:
        raise ApiError(
            404,
            "chat_session_not_found",
            "Чат-сессия не найдена.",
        )
    except SessionConflictError:
        raise ApiError(
            409,
            "chat_session_conflict",
            "Сначала подтвердите или отмените подготовленное действие.",
        )

    return {
        "text": result.text,
        "session_id": result.session_id,
        "requires_confirmation": result.requires_confirmation,
    }

Шаг 14

Очистка и удаление чат-сессии

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

Задача

Дополните две функции-эндпоинта из заготовки: clear_chat_session для очистки истории и delete_chat_session для удаления сессии. Каждая должна вызвать свой метод сервиса, вернуть пустой ответ со статусом 204 и преобразовать отсутствие сессии в ошибку 404.

Требования

  • Сохраните имена функций, HTTP-методы, пути и параметры из заготовки.
  • В clear_chat_session вызовите service.clear_session(session_id) ровно один раз.
  • В delete_chat_session вызовите service.delete_session(session_id) ровно один раз.
  • После успешного вызова каждая функция должна вернуть Response(status_code=204).
  • Успешные HTTP-ответы не должны содержать тело.
  • Если любой из методов сервиса выбросил SessionNotFoundError, выбросьте ApiError(404, "chat_session_not_found", "Чат-сессия не найдена.").
  • Не вызывайте service.create_session() и не создавайте новую сессию после очистки или удаления.

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

Очистка истории существующей сессии:

DELETE /chat/sessions/abc123/messages

204 No Content

Полное удаление существующей сессии:

DELETE /chat/sessions/abc123

204 No Content

Если session_id не найден, любой из этих запросов должен вернуть статус 404.

Правильный ответ
from fastapi import Depends, Response

from task_environment import ApiError, SessionNotFoundError, get_chat_service, router

@router.delete("/chat/sessions/{session_id}/messages", status_code=204)
def clear_chat_session(session_id: str, service=Depends(get_chat_service)):
    try:
        service.clear_session(session_id)
    except SessionNotFoundError:
        raise ApiError(
            404,
            "chat_session_not_found",
            "Чат-сессия не найдена.",
        )

    return Response(status_code=204)

@router.delete("/chat/sessions/{session_id}", status_code=204)
def delete_chat_session(session_id: str, service=Depends(get_chat_service)):
    try:
        service.delete_session(session_id)
    except SessionNotFoundError:
        raise ApiError(
            404,
            "chat_session_not_found",
            "Чат-сессия не найдена.",
        )

    return Response(status_code=204)

Шаг 15

Подтверждение и отмена действия

В заготовке уже объявлены функции-эндпоинты подтверждения и отмены подготовленного действия. Вам нужно вызвать соответствующий метод чат-сервиса, вернуть результат клиенту и обработать отсутствие сессии или действия со статусами 404 и 409.

Задача

Дополните функции-эндпоинты confirm_chat_action и cancel_chat_action. Вызовите в них соответствующие методы сервиса, сформируйте одинаковые по структуре успешные ответы и преобразуйте ошибки отсутствующей сессии и конфликта в статусы 404 и 409.

Требования

  • Сохраните имена функций, POST-пути и параметры из заготовки.
  • В confirm_chat_action вызовите service.confirm_pending(session_id) ровно один раз.
  • В cancel_chat_action вызовите service.cancel_pending(session_id) ровно один раз.
  • Не вызывайте метод подтверждения из маршрута отмены и метод отмены из маршрута подтверждения.
  • При SessionNotFoundError выбросьте ApiError(404, "chat_session_not_found", str(error)).
  • При SessionConflictError выбросьте ApiError(409, "chat_session_conflict", str(error)).
  • При успехе верните словарь ровно с ключами "text", "tool_name" и "status".
  • Значения ответа возьмите из одноимённых атрибутов результата соответствующего метода сервиса.
  • Тело HTTP-запроса для подтверждения и отмены не требуется.

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

Подтверждение подготовленного действия:

POST /chat/sessions/abc123/confirm

200 OK
Content-Type: application/json

{
  "text": "Действие выполнено",
  "tool_name": "create_return",
  "status": "success"
}

При запросе POST /chat/sessions/abc123/cancel структура ответа остаётся такой же, но поле "status" может содержать "cancelled". Неизвестная сессия должна привести к статусу 404, а отсутствие действия для подтверждения или отмены — к статусу 409.

Правильный ответ
from fastapi import Depends

from task_environment import (
    ApiError,
    SessionConflictError,
    SessionNotFoundError,
    get_chat_service,
    router,
)

@router.post("/chat/sessions/{session_id}/confirm")
def confirm_chat_action(session_id: str, service=Depends(get_chat_service)):
    try:
        result = service.confirm_pending(session_id)
    except SessionNotFoundError as error:
        raise ApiError(
            404,
            "chat_session_not_found",
            str(error),
        )
    except SessionConflictError as error:
        raise ApiError(
            409,
            "chat_session_conflict",
            str(error),
        )

    return {
        "text": result.text,
        "tool_name": result.tool_name,
        "status": result.status,
    }

@router.post("/chat/sessions/{session_id}/cancel")
def cancel_chat_action(session_id: str, service=Depends(get_chat_service)):
    try:
        result = service.cancel_pending(session_id)
    except SessionNotFoundError as error:
        raise ApiError(
            404,
            "chat_session_not_found",
            str(error),
        )
    except SessionConflictError as error:
        raise ApiError(
            409,
            "chat_session_conflict",
            str(error),
        )

    return {
        "text": result.text,
        "tool_name": result.tool_name,
        "status": result.status,
    }