Source code for smarter.apps.chatbot.api.v1.views.base

# pylint: disable=W0611
"""ChatBot api/v1/chatbots base view, for invoking a ChatBot."""

import logging
import traceback
from http import HTTPStatus
from typing import List, Optional
from urllib.parse import ParseResult, urlparse

from django.contrib.auth.models import User
from django.core.handlers.wsgi import WSGIRequest
from django.http import HttpResponseNotAllowed, JsonResponse
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from rest_framework.response import Response

from smarter.apps.chatbot.exceptions import SmarterChatBotException
from smarter.apps.chatbot.models import (
    ChatBot,
    ChatBotFunctions,
    ChatBotHelper,
    ChatBotPlugin,
)
from smarter.apps.chatbot.serializers import ChatBotSerializer
from smarter.apps.chatbot.signals import chatbot_called
from smarter.apps.plugin.plugin.base import PluginBase
from smarter.apps.prompt.models import ChatHelper
from smarter.apps.prompt.providers.providers import HandlerProtocol, chat_providers
from smarter.common.conf import smarter_settings
from smarter.common.const import SmarterHttpMethods
from smarter.common.utils import is_authenticated_request
from smarter.lib.django import waffle
from smarter.lib.django.views import SmarterAuthenticatedNeverCachedWebView
from smarter.lib.django.waffle import SmarterWaffleSwitches
from smarter.lib.journal.enum import (
    SmarterJournalApiResponseKeys,
    SmarterJournalCliCommands,
    SmarterJournalThings,
)
from smarter.lib.journal.http import (
    SmarterJournaledJsonErrorResponse,
    SmarterJournaledJsonResponse,
)
from smarter.lib.json import SmarterJSONEncoder
from smarter.lib.logging import WaffleSwitchedLoggerWrapper


# pylint: disable=W0613
[docs] def should_log(level): """Check if logging should be done based on the waffle switch.""" return waffle.switch_is_active(SmarterWaffleSwitches.CHATBOT_LOGGING)
base_logger = logging.getLogger(__name__) logger = WaffleSwitchedLoggerWrapper(base_logger, should_log) # pylint: disable=too-many-instance-attributes
[docs] @method_decorator(csrf_exempt, name="dispatch") class ChatBotApiBaseViewSet(SmarterAuthenticatedNeverCachedWebView): """ Base viewset for all ChatBot API endpoints. This class serves as the foundational viewset for all chatbot-related APIs in the Smarter platform, including prompt completions that leverage the Smarter LLM Tool Call Plugin architecture. **Key Responsibilities:** - **API Key Authentication and Request Validation:** Enforces authentication for all API requests, rejecting those without a valid API key. - **Lifecycle Management:** Handles initialization of Account, ChatBot, ChatBotHelper, and ChatHelper objects, and manages request dispatching and routing to the appropriate handler methods. - **Plugin Discovery and Extensibility:** Discovers and initializes plugins for chatbot extensibility, supporting the Smarter LLM Tool Call Plugin architecture. - **Logging and Observability:** Provides robust logging and observability for all major lifecycle events, including error handling. **Django Integration:** - Subclasses Django's view-template system (not DRF), participating in the standard request/response lifecycle. - Overrides and extends methods such as ``setup()``, ``dispatch()``, ``get()``, and ``post()`` to provide chatbot-specific logic. - CSRF-exempt to support API clients. **Prompt Completion & LLM Tool Call Plugins:** - This base view is designed to support prompt completion endpoints that utilize Smarter's LLM Tool Call Plugin architecture. - Plugins can be discovered and invoked as part of the chatbot's response generation, enabling extensible and dynamic tool use. **Examples:** - ``https://customer-support.3141-5926-5359.api.example.com/`` - ``https://platform.smarter/workbench/example/`` - ``https://platform.smarter/api/v1/workbench/1/chat/`` **Notes:** - Intended to be subclassed by concrete chatbot API views. - Provides robust error handling and logging for all major operations. - Authentication is enforced by default. - CSRF-exempt for API compatibility. **See Also:** - Django REST Framework View lifecycle: https://www.django-rest-framework.org/api-guide/views/#view-initialization - SmarterRequestMixin for request context management. - ChatBotHelper and ChatHelper for chatbot and chat session logic. - Smarter LLM Tool Call Plugin architecture documentation. """ _chatbot_id: Optional[int] = None _chatbot_helper: Optional[ChatBotHelper] = None _chat_helper: Optional[ChatHelper] = None _name: Optional[str] = None http_method_names: list[str] = ["get", "post", "options"] plugins: Optional[List[PluginBase]] = None functions: Optional[list[str]] = None
[docs] def __init__(self, *args, **kwargs): super().__init__(**kwargs)
@property def chatbot_id(self): """ Returns the chatbot ID. :return: The chatbot ID. :rtype: Optional[int] """ return self._chatbot_id @property def chat_helper(self) -> ChatHelper: """ Returns the ChatHelper instance. Lazily initializes the ChatHelper if it hasn't been created yet. :return: The ChatHelper instance. :rtype: ChatHelper """ if self._chat_helper: return self._chat_helper if self.session_key or self.chatbot: self._chat_helper = ChatHelper( request=self.smarter_request, session_key=self.session_key, chatbot=self.chatbot ) if self._chat_helper: self.helper_logger( f"{self.formatted_class_name} initialized with chat: {self.chat_helper.chat}, chatbot: {self.chatbot}" ) else: raise SmarterChatBotException( f"ChatHelper not found. request={self.smarter_request} name={self.name}, chatbot_id={self.chatbot_id}, session_key={self.session_key}, user_profile={self.user_profile}" ) return self._chat_helper @property def chatbot_helper(self) -> Optional[ChatBotHelper]: """ Returns the ChatBotHelper instance. Lazily initializes the ChatBotHelper if it hasn't been created yet. :return: The ChatBotHelper instance. :rtype: Optional[ChatBotHelper] """ if self._chatbot_helper: return self._chatbot_helper # ensure that we have some combination of properties that can identify a chatbot if not (self.url or self.chatbot_id or (self.user_profile and self.name)): return None try: self._chatbot_helper = ChatBotHelper( request=self.smarter_request, name=self.name, chatbot_id=self.chatbot_id, # SmarterRequestMixin should have set these properties session_key=self.session_key, # and these, for AccountMixin, account=self.account, user=self.user, user_profile=self.user_profile, ) # smarter.apps.chatbot.models.ChatBot.DoesNotExist: ChatBot matching query does not exist. except ChatBot.DoesNotExist as e: raise SmarterChatBotException( f"ChatBot not found. request={self.smarter_request} name={self.name}, chatbot_id={self.chatbot_id}, session_key={self.session_key}, user_profile={self.user_profile}" ) from e self._chatbot_id = self._chatbot_helper.chatbot_id if self._chatbot_id: logger.debug( "%s: %s initialized ChatBotHelper with id: %s, url: %s", self.formatted_class_name, self._chatbot_helper, self._chatbot_id, self._url, ) if self._chatbot_helper: logger.debug( "%s: %s ChatBotHelper reinitializing user: %s, account: %s", ) self._url = urlparse(self._chatbot_helper.url) # type: ignore self._user = self._chatbot_helper.user self._account = self._chatbot_helper.account logger.debug( "%s: %s initialized with url: %s id: %s", self.formatted_class_name, self._chatbot_helper, self.url, self.chatbot_id, ) return self._chatbot_helper @property def name(self): """ Returns the name of the chatbot. :return: The name of the chatbot. :rtype: Optional[str] """ if self._name: return self._name self._name = self._chatbot_helper.name if self._chatbot_helper else None @property def chatbot(self): """ Returns the ChatBot instance. :return: The ChatBot instance. :rtype: Optional[ChatBot] """ return self.chatbot_helper.chatbot if self.chatbot_helper else None @property def formatted_class_name(self) -> str: """ Returns the class name in a formatted string along with the name of this mixin. :return: Formatted class name string. :rtype: str """ inherited_class = super().formatted_class_name return f"{inherited_class} {ChatBotApiBaseViewSet.__name__}[{id(self)}]" @property def url(self) -> Optional[ParseResult]: """ Returns the URL of the chatbot. :return: The URL of the chatbot. :rtype: Optional[ParseResult] """ try: return self._url # pylint: disable=W0718 except Exception as e: logger.warning("%s: Error getting url: %s", self.formatted_class_name, e) @property def is_web_platform(self): """ Determine if the request is from the web platform domain. :return: True if the request is from the web platform domain, False otherwise. :rtype: bool """ host = self.smarter_request.get_host() if host in smarter_settings.environment_platform_domain: return True return False
[docs] def helper_logger(self, message: str): """ Create a log entry :param message: The message to log. :type message: str """ logger.debug("%s: %s", self.formatted_class_name, message)
[docs] def setup(self, request: WSGIRequest, *args, **kwargs): """ Set up the ChatBot API base viewset for request processing. This method is called as part of the Django REST Framework (DRF) view lifecycle, immediately after the view instance is created and before the request is dispatched to the appropriate handler method (such as ``get()`` or ``post()``). The primary responsibilities of this method are to: - Initialize the :class:`SmarterRequestMixin` with the current request and any additional arguments. - Prepare and set up the :class:`ChatBotHelper` and :class:`ChatHelper` instances, which are used throughout the request lifecycle for chatbot-specific logic and chat session management. - Log key setup events for observability and debugging. Parameters ---------- request : WSGIRequest The HTTP request object provided by Django, containing all request data, headers, and user context. *args Additional positional arguments passed to the view. **kwargs Additional keyword arguments passed to the view, often including URL parameters. Notes ----- - This method is a critical integration point with DRF's request/response lifecycle. - It ensures that all necessary context and helper objects are available before the main handler methods are called. - Subclasses may override this method to provide additional setup logic, but should always call ``super().setup()`` to preserve base functionality. See Also -------- - Django REST Framework View lifecycle: https://www.django-rest-framework.org/api-guide/views/#view-initialization - SmarterRequestMixin for request context management. - ChatBotHelper and ChatHelper for chatbot and chat session logic. """ logger.debug( "%s.setup() - request: %s, args: %s, kwargs: %s", self.formatted_class_name, self.smarter_build_absolute_uri(request), args, kwargs, ) return super().setup(request, *args, **kwargs)
[docs] def dispatch(self, request: WSGIRequest, *args, name: Optional[str] = None, **kwargs): """ Dispatch method for the ChatBot API base viewset. This method is invoked as part of the Django REST Framework (DRF) view lifecycle. It is responsible for preparing the viewset for request processing, including initializing the ChatBotHelper and ChatHelper instances, setting up the request context, and logging relevant information for observability and debugging. The dispatch method performs the following key actions: - Extracts and sets the chatbot ID from the URL parameters, if present. - Initializes the ChatBot and Account context for the request. - Validates the existence and readiness of the ChatBotHelper and ChatBot instances. - Handles error conditions such as missing or invalid chatbot configuration, returning appropriate HTTP error responses. - Loads and attaches plugins for the chatbot, if available. - Emits signals and logs key request metadata for auditing and debugging. - Calls the parent class's dispatch method to continue the DRF request/response lifecycle. Parameters ---------- request : WSGIRequest The HTTP request object provided by Django, containing all request data, headers, and user context. *args Additional positional arguments passed to the view. name : Optional[str] The name of the chatbot, if provided as a URL parameter. **kwargs Additional keyword arguments passed to the view, often including URL parameters. Returns ------- JsonResponse or HttpResponse A Django JsonResponse or HttpResponse object representing the result of the request, or an error response if initialization fails. Notes ----- - This method is a critical integration point with DRF's request/response lifecycle. - It ensures that all necessary context, helpers, and plugins are available before the main handler methods are called. - Subclasses may override this method to provide additional dispatch logic, but should always call ``super().dispatch()`` to preserve base functionality. See Also -------- - Django REST Framework View dispatch: https://www.django-rest-framework.org/api-guide/views/#view-methods - ChatBotHelper and ChatHelper for chatbot and chat session logic. """ self._chatbot_id = kwargs.get("chatbot_id") if self._chatbot_id: kwargs.pop("chatbot_id") if self.chatbot: self.user_profile = self.chatbot.user_profile else: self._name = self._name or name if not self.chatbot: logger.warning( "Could not initialize ChatBotHelper url: %s, name: %s, user: %s, account: %s, id: %s", self.url, self.name, self.user, self.account, self.chatbot_id, ) return JsonResponse({}, status=HTTPStatus.NOT_FOUND.value) logger.debug("%s.dispatch() - url=%s", self.formatted_class_name, self.url) logger.debug("%s.dispatch() - id=%s", self.formatted_class_name, self.chatbot_id) logger.debug("%s.dispatch() - name=%s", self.formatted_class_name, self.name) logger.debug("%s.dispatch() - account=%s", self.formatted_class_name, self.account) logger.debug("%s.dispatch() - chatbot=%s", self.formatted_class_name, self.chatbot) logger.debug("%s.dispatch() - user=%s", self.formatted_class_name, request.user) logger.debug("%s.dispatch() - method=%s", self.formatted_class_name, request.method) logger.debug("%s.dispatch() - body=%s", self.formatted_class_name, self.data) logger.debug("%s.dispatch() - headers=%s", self.formatted_class_name, request.META) if not self.chatbot_helper: raise SmarterChatBotException( f"ChatBotHelper not found. request={self.smarter_request} name={self.name}, chatbot_id={self.chatbot_id}, session_key={self.session_key}, user_profile={self.user_profile}" ) if not self.chatbot_helper.ready: data = { "data": { "error": { "message": "Could not initialize ChatBot object.", "account": self.account.account_number if self.account else None, "chatbot": ChatBotSerializer(self.chatbot).data if self.chatbot else None, "user": self.user.username if self.user else None, "name": self.chatbot_helper.name, "url": self.chatbot_helper.url, }, }, } return JsonResponse(data=data, status=HTTPStatus.BAD_REQUEST.value) if self.chatbot_helper.is_authentication_required and not is_authenticated_request(request): data = {"message": "Forbidden. Please provide a valid API key."} return JsonResponse(data=data, status=HTTPStatus.FORBIDDEN.value) self.plugins = ChatBotPlugin().plugins(chatbot=self.chatbot) self.functions = ChatBotFunctions().functions(chatbot=self.chatbot) if self.chatbot_helper.is_chatbot: logger.debug("%s.dispatch(): account=%s", self.formatted_class_name, self.account) logger.debug("%s.dispatch(): chatbot=%s", self.formatted_class_name, self.chatbot) logger.debug("%s.dispatch(): user=%s", self.formatted_class_name, self.user) logger.debug("%s.dispatch(): plugins=%s", self.formatted_class_name, self.plugins) logger.debug("%s.dispatch(): functions=%s", self.formatted_class_name, self.functions) logger.debug("%s.dispatch(): name=%s", self.formatted_class_name, self.name) logger.debug("%s.dispatch(): data=%s", self.formatted_class_name, self.data) if self.session_key: logger.debug("%s.dispatch(): session_key=%s", self.formatted_class_name, self.session_key) logger.debug("%s.dispatch(): chat_helper=%s", self.formatted_class_name, self.chat_helper) if self.chatbot_helper.is_chatbot and self.chat_helper: chatbot_called.send( sender=self.__class__, chatbot=self.chatbot, request=request, data=self.data, args=args, kwargs=kwargs ) return super().dispatch(request, *args, **kwargs)
[docs] def options(self, request, *args, **kwargs): """ OPTIONS request handler for the Smarter Chat API. Sets CORS headers to allow cross-origin requests from the Smarter environment URL. :param request: The HTTP request object. :type request: WSGIRequest """ logger.debug( "%s.options(): url=%s", self.formatted_class_name, self.chatbot_helper.url if self.chatbot_helper else "(Missing ChatBotHelper.url)", ) response = Response() response["Access-Control-Allow-Origin"] = smarter_settings.environment_url response["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS" response["Access-Control-Allow-Headers"] = "origin, content-type, accept" return response
# pylint: disable=W0613
[docs] def get(self, request, *args, name: Optional[str] = None, **kwargs): """ GET request handler for the Smarter Chat API. Currently, GET requests are not supported and will return a message indicating that POST should be used instead. :param request: The HTTP request object. :type request: WSGIRequest :return: A JsonResponse indicating that GET is not supported. :rtype: JsonResponse """ return HttpResponseNotAllowed(permitted_methods=[SmarterHttpMethods.POST])
# pylint: disable=W0613
[docs] def post(self, request, *args, name: Optional[str] = None, **kwargs): """ POST request handler for the Smarter Chat API. This method processes POST requests to the chatbot API endpoint. It determines which ChatBot instance to use based on the request's host, supporting both default API domains and custom domains. The logic ensures that the correct ChatBot is selected for each request, and that all necessary context and helpers are available for downstream processing. Hostname Resolution ------------------- The ChatBot instance is determined by parsing the request host. There are two supported formats: 1. **URL with default API domain** Example: ``https://customer-support.3141-5926-5359.api.example.com/chatbot/`` - ``customer-support``: The chatbot's name. - ``3141-5926-5359``: The chatbot's account number. - ``api.example.com``: The default API domain. 2. **URL with custom domain** Example: ``https://api.example.com/chatbot/`` - ``api.example.com``: The chatbot's custom domain. - The custom domain must be verified (``ChatBotCustomDomain.is_verified == True``). The ChatBot instance hostname is determined by: ``chatbot.hostname == chatbot.custom_domain or chatbot.default_host`` Processing Steps ---------------- - Logs key request and context information for observability. - Validates that a ChatBot instance is available; returns an error response if not found. - Retrieves the appropriate chat provider handler for the ChatBot. - Ensures a valid ChatHelper instance is available; returns an error response if not found. - Invokes the chat provider handler with the chat session, request data, plugins, and user context. - Wraps the response in a ``SmarterJournaledJsonResponse`` for consistent API output. Parameters ---------- request : WSGIRequest The HTTP request object provided by Django, containing all request data, headers, and user context. *args Additional positional arguments passed to the view. name : Optional[str] The name of the chatbot, if provided as a URL parameter. **kwargs Additional keyword arguments passed to the view, often including URL parameters. Returns ------- SmarterJournaledJsonResponse A structured JSON response containing the result of the chat operation, or an error response if the ChatBot or ChatHelper could not be initialized. Notes ----- - This method is a critical integration point for chatbot conversations in the Smarter platform. - It enforces domain-based routing and robust error handling for missing or invalid chatbot context. - The response format is standardized for journaling and auditing purposes. See Also -------- - Django REST Framework APIView: https://www.django-rest-framework.org/api-guide/views/ - SmarterJournaledJsonResponse for response structure. - ChatBotHelper and ChatHelper for chatbot and chat session logic. """ logger.debug( "%s.post() - provider=%s", self.formatted_class_name, self.chatbot.provider if self.chatbot else None ) logger.debug("%s.post() - data=%s", self.formatted_class_name, self.data) logger.debug("%s.post() - account: %s - %s", self.formatted_class_name, self.account, self.account_number) logger.debug("%s.post() - user: %s", self.formatted_class_name, self.user) logger.debug( "%s.post() - chat: %s", self.formatted_class_name, self.chat_helper.chat.user_profile if self.chat_helper and self.chat_helper.chat else None, ) logger.debug("%s.post() - chatbot: %s", self.formatted_class_name, self.chatbot) logger.debug("%s.post() - plugins: %s", self.formatted_class_name, self.plugins) if not self.chatbot: return SmarterJournaledJsonErrorResponse( request=request, e=SmarterChatBotException( f"ChatBot not found. request={self.smarter_request} name={self.name}, chatbot_id={self.chatbot_id}, session_key={self.session_key}, user_profile={self.user_profile}" ), safe=False, thing=SmarterJournalThings(SmarterJournalThings.CHATBOT), command=SmarterJournalCliCommands(SmarterJournalCliCommands.CHAT), status=HTTPStatus.NOT_FOUND.value, stack_trace=traceback.format_exc(), ) handler: HandlerProtocol = chat_providers.get_handler(provider=self.chatbot.provider) if not self.chat_helper: return SmarterJournaledJsonErrorResponse( request=request, e=SmarterChatBotException( f"ChatHelper not found. request={self.smarter_request} name={self.name}, chatbot_id={self.chatbot_id}, session_key={self.session_key}, user_profile={self.user_profile}" ), safe=False, thing=SmarterJournalThings(SmarterJournalThings.CHATBOT), command=SmarterJournalCliCommands(SmarterJournalCliCommands.CHAT), status=HTTPStatus.NOT_FOUND.value, stack_trace=traceback.format_exc(), ) if not self.chat_helper.chat: raise SmarterChatBotException( f"Chat not found. This is a bug. request={self.smarter_request} name={self.name}, chatbot_id={self.chatbot_id}, session_key={self.session_key}, user_profile={self.user_profile}" ) if not self.data: raise SmarterChatBotException( f"POST data is empty. request={self.smarter_request} name={self.name}, chatbot_id={self.chatbot_id}, session_key={self.session_key}, user_profile={self.user_profile}" ) # RequestMixin.data is more relaxed than the provider expects, so we validate here if not isinstance(self.data, (dict, list)): raise SmarterChatBotException( f"POST data is not a dict or list. request={self.smarter_request} name={self.name}, chatbot_id={self.chatbot_id}, session_key={self.session_key}, user_profile={self.user_profile}, data_type={type(self.data)}" ) # likewise, AccountMixin.user can accept AnonymousUser, but providers expect a real User if not isinstance(self.user, User): raise SmarterChatBotException( f"User is not a valid User instance. request={self.smarter_request} name={self.name}, chatbot_id={self.chatbot_id}, session_key={self.session_key}, user_profile={self.user_profile}, user_type={type(self.user)}" ) response = handler(self.user, self.chat_helper.chat, self.data, plugins=self.plugins, functions=self.functions) response = { SmarterJournalApiResponseKeys.DATA: response, } response = SmarterJournaledJsonResponse( request=request, data=response, command=SmarterJournalCliCommands(SmarterJournalCliCommands.CHAT), thing=SmarterJournalThings(SmarterJournalThings.CHATBOT), status=HTTPStatus.OK.value, safe=False, ) self.helper_logger(f"{self.formatted_class_name} response={response}") return response