# pylint: disable=W0613,C0302
"""
PromptConfigView is a Django class-based view responsible for providing.
configuration data to the ReactJS prompt UI component in the Smarter
web application.
"""
import logging
from http import HTTPStatus
from typing import Any, Optional, Union
from django.http import (
HttpRequest,
HttpResponse,
HttpResponseNotAllowed,
JsonResponse,
)
from smarter.apps.llm_client.models import (
LLMClient,
LLMClientFunctions,
LLMClientHelper,
LLMClientPlugin,
LLMClientRequests,
get_cached_llm_client_by_request,
)
from smarter.apps.llm_client.serializers import (
LLMClientConfigSerializer,
LLMClientFunctionsSerializer,
LLMClientPluginSerializer,
LLMClientRequestsSerializer,
)
from smarter.apps.plugin.models import (
PluginSelectorHistory,
PluginSelectorHistorySerializer,
)
from smarter.apps.prompt.signals import chat_config_invoked
from smarter.apps.prompt.views.detailviews.prompt_workbench_view import (
SmarterPromptSession,
)
from smarter.common.conf import smarter_settings
from smarter.common.const import (
SMARTER_CHAT_SESSION_KEY_NAME,
SmarterHttpMethods,
)
from smarter.common.helpers.url_helpers import clean_url
from smarter.common.utils import is_authenticated_request
from smarter.lib.django import waffle
from smarter.lib.django.http.shortcuts import (
SmarterHttpResponseForbidden,
)
from smarter.lib.django.views import (
SmarterAuthenticatedNeverCachedWebView,
)
from smarter.lib.django.waffle import SmarterWaffleSwitches
from smarter.lib.drf.views.helpers import UnauthenticatedPermissionClass
from smarter.lib.journal.enum import SmarterJournalCliCommands, SmarterJournalThings
from smarter.lib.journal.http import (
SmarterJournaledJsonErrorResponse,
SmarterJournaledJsonResponse,
)
from smarter.lib.logging import WaffleSwitchedLoggerWrapper
MAX_RETURNED_PLUGINS = 10
[docs]
def should_log(level):
"""Check if logging should be done based on the waffle switch."""
return waffle.switch_is_active(SmarterWaffleSwitches.PROMPT_LOGGING)
base_logger = logging.getLogger(__name__)
logger = WaffleSwitchedLoggerWrapper(base_logger, should_log)
[docs]
def should_log_verbose(level):
"""Check if logging should be done based on the waffle switch."""
return smarter_settings.verbose_logging
verbose_logger = WaffleSwitchedLoggerWrapper(base_logger, should_log_verbose)
[docs]
class PromptConfigView(SmarterAuthenticatedNeverCachedWebView):
"""
Prompt configuration view for the Smarter web application.
This view is responsible for providing all configuration information required by the ReactJS prompt UI component.
It is designed to be performant and efficient, as it is invoked on every user-facing browser load and refresh.
**Key Features:**
- **Django Template-Based:**
This view uses Django's template system for rendering and does not rely on Django REST Framework (DRF) serializers for its main response.
The configuration data is returned as a JSON response, but the view itself is structured as a Django class-based view.
- **CSRF Exempt:**
The view is decorated with `@csrf_exempt` because it is read-only and does not modify server-side data. This exemption is safe in this context
and avoids unnecessary CSRF validation for GET and POST requests that only retrieve configuration data.
- **ReactJS UI Integration:**
The endpoint provides all necessary configuration and context information for the ReactJS prompt component. This includes llm_client metadata,
plugin information, session keys, and historical data relevant to the prompt session. The React app consumes this configuration to initialize
and render the prompt UI in the user's browser.
- **Session-Based Architecture:**
Each prompt session is uniquely identified and managed by a globally unique 64-character session key. The session key is generated by
RequestHelper.generate_session_key() and is persisted by the browser in a domain and path-specific cookie, meaning that this design supports
an unlimited number of concurrent sessions per user, one per device per llm_client.
- **Performance Considerations:**
Since this endpoint is called on every browser load and refresh, performance is a primary concern. The view is optimized to minimize database
queries and serialization overhead. Only a limited number of plugins (as defined by `MAX_RETURNED_PLUGINS`) are returned to avoid excessive payload sizes.
Caching and efficient queryset usage are employed where possible to ensure fast response times for end users.
**Example Usage:**
https://smarter.3141-5926-5359.alpha.api.example.com/config/
**Returns:**
JSON response containing:
- `session_key`: Unique identifier for the prompt session.
- `sandbox_mode`: Boolean indicating if the llm_client is running in sandbox mode.
- `debug_mode`: Boolean indicating if debug mode is enabled.
- `llm_client`: Serialized llm_client configuration.
- `history`: Prompt and plugin selector history for the session.
- `meta_data`: Additional metadata for the llm_client.
- `plugins`: Plugin metadata and a limited list of plugins.
**Security:**
- The view is protected and requires the user to be authenticated, unless the llm_client is configured to allow unauthenticated access.
- If authentication is required and the user is not authenticated, a 403 Forbidden response is returned.
**See Also:**
- `SmarterPromptSession`: Helper class for managing prompt sessions.
- `LLMClientConfigSerializer`, `LLMClientPluginSerializer`: Serializers for llm_client and plugin data.
- `LLMClientHelper`: Helper for llm_client-related operations.
"""
authentication_classes = None
permission_classes = (UnauthenticatedPermissionClass,)
thing: Optional[SmarterJournalThings] = None
command: Optional[SmarterJournalCliCommands] = None
session: Optional[SmarterPromptSession] = None
llm_client_name: Optional[str] = None
_llm_client_helper: Optional[LLMClientHelper] = None
_llm_client: Optional[LLMClient] = None
@property
def formatted_class_name(self) -> str:
"""Returns a formatted string of the class name for logging purposes."""
class_name = f"{__name__}.{PromptConfigView.__name__}[{id(self)}]"
return self.formatted_text(class_name)
@property
def llm_client(self) -> Optional[LLMClient]:
return self._llm_client
@property
def llm_client_helper(self) -> Optional[LLMClientHelper]:
if self._llm_client_helper:
return self._llm_client_helper
if self.llm_client:
# throw everything but the kitchen sink at the LLMClientHelper
self._llm_client_helper = LLMClientHelper(
request=self.smarter_request,
session_key=self.session.session_key if self.session else None,
name=self._llm_client.name if self._llm_client else self.llm_client_name,
llm_client_id=self._llm_client.id if self._llm_client else None, # type: ignore[union-attr]
account=self.account,
user=self.user,
user_profile=self.user_profile,
)
else:
self._llm_client_helper = LLMClientHelper(
request=self.smarter_request,
name=self.llm_client_name or self.smarter_request_llm_client_name,
session_key=self.session.session_key if self.session else None,
account=self.account,
user=self.user,
user_profile=self.user_profile,
)
return self._llm_client_helper
@llm_client_helper.setter
def llm_client_helper(self, value: Optional[LLMClientHelper]):
self._llm_client_helper = value
if self._llm_client_helper and self._llm_client_helper.llm_client:
self._llm_client = self._llm_client_helper.llm_client
self.account = self._llm_client_helper.account
self.user = self._llm_client_helper.user
self.user_profile = self._llm_client_helper.user_profile
verbose_logger.debug(
"%s - llm_client_helper() setter llm_client=%s", self.formatted_class_name, self.llm_client
)
else:
self._llm_client = None
verbose_logger.debug("%s - llm_client_helper() setter llm_client is unset", self.formatted_class_name)
[docs]
def clean_url(self, url: str) -> str:
"""Clean the url of any query strings and trailing '/config/' strings."""
retval = clean_url(url)
if retval.endswith("/config/"):
retval = retval[:-8]
return retval
[docs]
def legacy_config(self, config: Union[dict[str, Any], list[Any]], replace_str: str, with_str: str) -> Any:
"""
Recursively replaces any key value of 'llm_client' with 'chatbot' for legacy.
support of older versions of the React app that expect 'chatbot' instead of 'llm_client'.
"""
if isinstance(config, dict):
retval = {}
for key, value in config.items():
if isinstance(value, (dict, list)):
value = self.legacy_config(value, replace_str, with_str)
if replace_str in key:
key = key.replace(replace_str, with_str)
if key.lower().replace("_", "") == replace_str.lower().replace("_", ""):
key = with_str
retval[key] = value
elif isinstance(config, list):
retval = [
self.legacy_config(item, replace_str, with_str) if isinstance(item, (dict, list)) else item
for item in config
]
else:
return config
return retval
[docs]
def config(self) -> dict[str, Any]:
"""
Assemble and return the configuration dictionary required by the ReactJS prompt UI component.
This method gathers all relevant context and configuration data for the prompt session and llm_client,
and returns it as a dictionary suitable for JSON serialization. This configuration is consumed by
the ReactJS frontend to initialize and render the prompt interface.
**Key Features:**
- **Django Template-Based:**
This logic is part of a Django class-based view and does not use Django REST Framework (DRF) serializers
for the main response, though serializers are used for some nested data.
- **CSRF Exempt:**
The parent view is CSRF exempt because this endpoint is strictly read-only and does not modify server-side data.
- **ReactJS UI Integration:**
The returned dictionary provides all configuration and context information needed by the ReactJS prompt component,
including llm_client metadata, plugin information, session keys, and prompt history.
- **Session-Based:**
The configuration is tied to a unique prompt session, as defined by the :class:`SmarterPromptSession` helper,
which uses a combination of the user's IP address and device-identifying information to uniquely identify
each session.
- **Performance:**
Since this endpoint is called on every browser load and refresh, the logic is optimized to minimize database
queries and serialization overhead. Only a limited number of plugins (see ``MAX_RETURNED_PLUGINS``) are returned
to keep payloads small and response times fast.
Returns
-------
dict
A dictionary containing all configuration data required by the ReactJS prompt UI component. The structure includes:
- ``session_key``: Unique identifier for the prompt session.
- ``sandbox_mode``: Boolean indicating if the llm_client is running in sandbox mode.
- ``debug_mode``: Boolean indicating if debug mode is enabled.
- ``llm_client``: Serialized llm_client configuration.
- ``history``: Prompt and plugin selector history for the session.
- ``meta_data``: Additional metadata for the llm_client.
- ``plugins``: Plugin metadata and a limited list of plugins.
Raises
------
SmarterValueError
If the session or llm_client helper is not set.
See Also
--------
SmarterPromptSession : Helper class for managing prompt sessions.
LLMClientConfigSerializer, LLMClientPluginSerializer : Serializers for llm_client and plugin data.
LLMClientHelper : Helper for llm_client-related operations.
"""
# add llm_client_request_history and plugin_selector_history to history
# these have to be added here due to circular import issues.
if self.session is None:
logger.error(
"%s.config() - session is None. Cannot retrieve llm_client request history.", self.formatted_class_name
)
return {}
if self.llm_client_helper is None:
logger.error(
"%s.config() - LLMClientHelper is None. Cannot retrieve llm_client request history.",
self.formatted_class_name,
)
return {}
llm_client_serializer = (
LLMClientConfigSerializer(self.llm_client, context={"request": self.smarter_request})
if self.llm_client
else None
)
# plugins context. the main thing we need here is to constrain the number of plugins
# returned to some reasonable number, since we'll probaably have cases where
# the llm_client has a lot of plugins (hundreds, thousands...).
llm_client_plugins_count = LLMClientPlugin.objects.filter(llm_client=self.llm_client).count()
llm_client_plugins = LLMClientPlugin.objects.filter(llm_client=self.llm_client).order_by("-pk")[
:MAX_RETURNED_PLUGINS
]
llm_client_plugin_serializer = LLMClientPluginSerializer(llm_client_plugins, many=True)
llm_client_functions_count = LLMClientFunctions.objects.filter(llm_client=self.llm_client).count()
llm_client_functions = LLMClientFunctions.objects.filter(llm_client=self.llm_client).order_by("-pk")[
:MAX_RETURNED_PLUGINS
]
llm_client_functions_serializer = LLMClientFunctionsSerializer(llm_client_functions, many=True)
history = self.session.chat_helper.history if self.session.chat_helper else {}
llm_client_requests_queryset = LLMClientRequests.objects.filter(session_key=self.session.session_key).order_by(
"-id"
)
llm_client_requests_serializer = LLMClientRequestsSerializer(llm_client_requests_queryset, many=True)
history["llm_client_request_history"] = llm_client_requests_serializer.data
plugin_selector_history_queryset = PluginSelectorHistory.objects.filter(session_key=self.session.session_key)
plugin_selector_history_serializer = PluginSelectorHistorySerializer(
plugin_selector_history_queryset, many=True
)
history["plugin_selector_history"] = plugin_selector_history_serializer.data
retval = {
"data": {
SMARTER_CHAT_SESSION_KEY_NAME: self.session.session_key,
"sandbox_mode": self.llm_client_helper.is_llm_client_sandbox_url,
"debug_mode": waffle.switch_is_active(SmarterWaffleSwitches.ENABLE_REACTAPP_DEBUG_MODE),
"llm_client": llm_client_serializer.data if llm_client_serializer else None,
"history": history,
"meta_data": self.llm_client_helper.to_json(),
"plugins": {
"meta_data": {
"total_plugins": llm_client_plugins_count,
"plugins_returned": len(llm_client_plugins),
},
"plugins": llm_client_plugin_serializer.data,
},
"functions": {
"meta_data": {
"total_functions": llm_client_functions_count,
"functions_returned": len(llm_client_functions),
},
"functions": llm_client_functions_serializer.data,
},
},
}
# Legacy support for older versions of the React app that expect
# 'chatbot' instead of 'llm_client'
# ---------------------------------------------------------------------
retval = self.to_snake_case(retval, convert_values=True)
retval = self.legacy_config(retval, replace_str="llm_client", with_str="chatbot")
retval = self.legacy_config(retval, replace_str="account_number", with_str="accountNumber")
chat_config_invoked.send(sender=self.__class__, instance=self, request=self.smarter_request, data=retval)
return retval
[docs]
def dispatch(
self, request: HttpRequest, *args, **kwargs
) -> Union[JsonResponse, HttpResponse, SmarterJournaledJsonErrorResponse]:
"""
Handles incoming HTTP requests for the prompt configuration endpoint.
This method is responsible for orchestrating the retrieval and assembly of all configuration data
required by the ReactJS prompt UI component. It is invoked on every user-facing browser load and refresh,
making performance a critical concern.
**Key Details:**
- **Django Template-Based:**
This view uses Django's class-based view and template system, not Django REST Framework (DRF).
The response is a JSON object, but the view logic is not DRF-based.
- **CSRF Exempt:**
The view is decorated with ``@csrf_exempt`` because it is strictly read-only and does not modify server-side data.
This avoids unnecessary CSRF validation for GET and POST requests that only retrieve configuration data.
- **ReactJS UI Integration:**
The endpoint provides all configuration and context information needed by the ReactJS prompt component,
including llm_client metadata, plugin information, session keys, and prompt history.
- **Session-Based:**
Sessions are managed by the :class:`SmarterPromptSession` helper, which uniquely defines a prompt session
using a combination of the user's IP address and device-identifying information. This ensures each device/browser
instance receives a unique session key, which is used to track prompt history and plugin usage.
- **Performance:**
Since this endpoint is called on every browser load and refresh, it is optimized to minimize database queries
and serialization overhead. Only a limited number of plugins are returned (see ``MAX_RETURNED_PLUGINS``)
to keep payloads small and response times fast.
Parameters
----------
request : HttpRequest
The incoming HTTP request object.
llm_client_id : Optional[int], default=None
The ID of the llm_client to retrieve configuration for, if specified.
*args
Additional positional arguments.
**kwargs
Additional keyword arguments.
Returns
-------
Union[JsonResponse, HttpResponse, SmarterJournaledJsonErrorResponse]
A JSON response containing all configuration data required by the ReactJS prompt UI component,
or an error response if the request is invalid or unauthorized.
See Also
--------
SmarterPromptSession : Helper class for managing prompt sessions.
LLMClientConfigSerializer, LLMClientPluginSerializer : Serializers for llm_client and plugin data.
LLMClientHelper : Helper for llm_client-related operations.
"""
hashed_id = kwargs.pop("hashed_id", None)
if hashed_id:
llm_client_id = LLMClient.id_from_hashed_id(hashed_id)
else:
llm_client_id = kwargs.pop("llm_client_id", None)
if llm_client_id is not None:
try:
self._llm_client = LLMClient.get_cached_object(pk=llm_client_id)
self.llm_client_name = self._llm_client.name
verbose_logger.debug(
"%s.dispatch() - set llm_client=%s from llm_client_id=%s",
self.formatted_class_name,
self._llm_client,
llm_client_id,
)
except LLMClient.DoesNotExist:
verbose_logger.error(
"%s.dispatch() - LLMClient with id=%s does not exist. Returning 404.",
self.formatted_class_name,
llm_client_id,
)
return JsonResponse({"error": "Not found"}, status=HTTPStatus.NOT_FOUND.value)
else:
self.llm_client_name = kwargs.get("name")
try:
self._llm_client = get_cached_llm_client_by_request(request=request)
if not self._llm_client:
verbose_logger.debug(
"%s.dispatch() - get_cached_llm_client_by_request() returned None. Attempting to instantiate LLMClientHelper with additional info",
self.formatted_class_name,
)
self.llm_client_helper = LLMClientHelper(
request=self.smarter_request,
session_key=self.session_key,
llm_client_id=llm_client_id,
name=self.llm_client_name,
account=self.account,
user=self.user,
user_profile=self.user_profile,
)
except LLMClient.DoesNotExist:
return JsonResponse({"error": "Not found"}, status=HTTPStatus.NOT_FOUND.value)
# Initialize the prompt session for this request. session_key is generated
# and managed by the /config/ endpoint for the llm_client
#
# examples:
# - https://customer-support.3141-5926-5359.api.example.com/workbench/config/?session_key=123456
# - http://localhost:9357/api/v1/llm-clients/1556/prompt/config/?session_key=0733c7b3a33e7eb733c95f6d7fc88d671b6c957e6857a04d05b3f5905167116f
#
# The React app calls this endpoint at app initialization to get a
# json dict that includes, among other pertinent info, this session_key
# which uniquely identifies the device and the individual llm_client session
# for the device.
self.session = SmarterPromptSession(request, session_key=self.session_key, llm_client=self.llm_client)
if (
self.llm_client_helper
and self.llm_client_helper.is_authentication_required
and not is_authenticated_request(request)
):
return SmarterHttpResponseForbidden(
request,
"Authentication failed. Are you logged in? Smarter sessions automatically expire after 24 hours.",
)
verbose_logger.debug(
"%s - llm_client=%s - llm_client_helper=%s",
self.formatted_class_name,
self.llm_client,
self.llm_client_helper,
)
if not self.llm_client:
return JsonResponse({"error": "Not found"}, status=HTTPStatus.NOT_FOUND.value)
self.thing = SmarterJournalThings(SmarterJournalThings.CHAT_CONFIG)
self.command = SmarterJournalCliCommands(SmarterJournalCliCommands.CHAT_CONFIG)
verbose_logger.debug(
"%s.dispatch() completed with llm_client=%s, session_key=%s",
self.formatted_class_name,
self.llm_client,
self.session.session_key if self.session else "(Missing session)",
)
return super().dispatch(request, *args, **kwargs) # type: ignore[return-value]
def __str__(self):
return str(self.llm_client) if self.llm_client else "PromptConfigView"
# pylint: disable=unused-argument
[docs]
def post(self, request: HttpRequest, *args, **kwargs) -> SmarterJournaledJsonResponse:
"""Get the llm_client configuration."""
logger.debug("%s - post()", self.formatted_class_name)
data = self.config()
return SmarterJournaledJsonResponse(request=request, data=data, thing=self.thing, command=self.command)
# pylint: disable=unused-argument
[docs]
def get(self, request: HttpRequest, *args, **kwargs) -> Union[SmarterJournaledJsonResponse, HttpResponseNotAllowed]:
"""Get the llm_client configuration."""
return self.post(request, *args, **kwargs)
logger.warning(
"%s - get() %s should be invoked via POST instead of GET.", self.formatted_class_name, request.path
)
if not waffle.switch_is_active(SmarterWaffleSwitches.ALLOW_API_GET):
logger.error(
"%s.get() %s is not allowed because %s switch is inactive.",
self.formatted_class_name,
request.path,
SmarterWaffleSwitches.ALLOW_API_GET,
)
return HttpResponseNotAllowed(permitted_methods=[SmarterHttpMethods.POST])
data = self.config()
return SmarterJournaledJsonResponse(request=request, data=data, thing=self.thing, command=self.command)