# pylint: disable=W0613,C0302
"""
PromptWorkbenchView is a Django class-based view responsible for serving the.
main prompt application page within the Smarter dashboard web app. It integrates
the ReactJS prompt UI with the Django template system by injecting a React build
artifact snippet (served from an AWS Cloudfront CDN) into the Django-rendered
HTML template. The React app then takes over the UI from there.
"""
import logging
import traceback
from http import HTTPStatus
from typing import Optional
from django.conf import settings
from django.http import (
HttpRequest,
)
from django.shortcuts import render
from smarter.apps.llm_client.models import (
LLMClient,
LLMClientHelper,
get_cached_llm_client_by_request,
)
from smarter.apps.prompt.models import Prompt, PromptHelper
from smarter.apps.prompt.signals import chat_session_invoked
from smarter.common.conf import smarter_settings
from smarter.common.const import (
SMARTER_CHAT_SESSION_KEY_NAME,
)
from smarter.common.exceptions import (
SmarterException,
)
from smarter.common.helpers.console_helpers import formatted_json
from smarter.common.helpers.url_helpers import clean_url
from smarter.common.mixins import SmarterHelperMixin
from smarter.lib.django import waffle
from smarter.lib.django.http.shortcuts import (
SmarterHttpResponseForbidden,
SmarterHttpResponseNotFound,
SmarterHttpResponseServerError,
)
from smarter.lib.django.views import (
SmarterAuthenticatedNeverCachedWebView,
)
from smarter.lib.django.waffle import SmarterWaffleSwitches
from smarter.lib.logging import WaffleSwitchedLoggerWrapper
MAX_RETURNED_PLUGINS = 10
PROMPT_LIST_CACHE_TIMEOUT = smarter_settings.cache_expiration
WORKBENCH_CACHE_TIMEOUT = 10 # 10 seconds. keeps the workbench snappy while avoiding appearing stale.
[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 SmarterChatappViewError(SmarterException):
"""Base class for all SmarterChatapp errors."""
@property
def get_formatted_err_message(self):
return "Smarter Chatapp error"
[docs]
class SmarterPromptSession(SmarterHelperMixin):
"""Helper class that provides methods for creating a session key and client key."""
_chat: Optional[Prompt] = None
_chat_helper: Optional[PromptHelper] = None
_llm_client: Optional[LLMClient] = None
request: Optional[HttpRequest] = None
_session_key: str
@property
def formatted_class_name(self) -> str:
"""Returns a formatted string of the class name for logging purposes."""
class_name = f"{__name__}.{SmarterPromptSession.__name__}[{id(self)}]"
return self.formatted_text(class_name)
[docs]
def __init__(self, request: HttpRequest, session_key: str, *args, llm_client: Optional[LLMClient] = None, **kwargs):
super().__init__()
verbose_logger.debug(
"SmarterPromptSession().__init__() called with session_key=%s, llm_client=%s", session_key, llm_client
)
self.request = request
if not isinstance(session_key, str):
logger.error("%s - session_key is not a string: %s", self.formatted_class_name, type(session_key))
self._session_key = session_key
if llm_client:
self._llm_client = llm_client
self.user_profile = llm_client.user_profile
# leaving this in place as a reminder that we need one or the other
if not self.session_key and not self.llm_client:
logger.error("%s - either session_key or llm_client must be provided", self.formatted_class_name)
self._chat_helper = PromptHelper(
request, *args, session_key=self.session_key, llm_client=self.llm_client, **kwargs
)
self._chat = self._chat_helper.prompt
verbose_logger.debug("%s - session established: %s", self.formatted_class_name, self.session_key)
chat_session_invoked.send(sender=self.__class__, instance=self, request=request)
def __str__(self):
return (
f"{SmarterPromptSession.__name__}[{id(self)}](llm_client={self.llm_client}, session_key={self.session_key})"
)
def __repr__(self):
return self.__str__()
def __eq__(self, value: object) -> bool:
return isinstance(value, SmarterPromptSession) and self.session_key == value.session_key
@property
def session_key(self) -> str:
"""
The session key for this prompt session.
This is used to identify the prompt session
and is generated by the /config/ endpoint.
"""
if self._session_key is None:
logger.error(
"%s - session_key is None. This should not happen. Please report this issue to the Smarter team.",
self.formatted_class_name,
)
return self._session_key
@property
def llm_client(self):
return self._llm_client
@property
def prompt(self):
return self._chat
@property
def chat_helper(self):
return self._chat_helper
[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]
class PromptWorkbenchView(SmarterAuthenticatedNeverCachedWebView):
"""
Prompt app view for the Smarter web application.
This view is responsible for serving the main prompt application page within the Smarter dashboard web app.
It integrates the ReactJS prompt UI with the Django template system by injecting a React build artifact snippet
(served from an AWS Cloudfront CDN) into the Django-rendered HTML template. The React app then takes over the UI
from there.
**Key Features:**
- **Django Template Integration:**
The view uses Django's template system to render the main prompt page. It injects the React app's loader script
and root div into the template, allowing seamless integration between Django and React.
- **ReactJS UI Bootstrapping:**
The React build (JavaScript and CSS) is loaded from a CDN and injected into the DOM. The React app is responsible
for rendering the interactive prompt UI after the initial page load.
- **Flexible URL Patterns:**
The view supports both sandbox and production URL formats, allowing it to work with deployed and not-yet-deployed LLMClients.
- **Authentication Protected:**
This view requires the user to be authenticated. If the user is not authenticated, access is denied.
- **Cache Control:**
The view uses Django's `never_cache` decorator to ensure that the browser does not cache the prompt page itself.
This prevents issues where a user logs out and then logs back in without a full page refresh.
**Example Usage:**
Sandbox mode:
- http://smarter.sh/workbench/hr/
- http://127.0.0.1:9357/workbench/<str:name>/
Production mode:
- https://hr.3141-5926-5359.alpha.api.example.com/workbench/
**Returns:**
Renders the Django template for the prompt app, injecting the React loader and configuration context.
**See Also:**
- `PromptConfigView` — for the endpoint that provides configuration data to the React app.
"""
template_path = "prompt/workbench.html"
# The React app originates from
# - https://github.com/smarter-sh/smarter-prompt and
# - https://github.com/smarter-sh/web-integration-example
# and is built-deployed to AWS Cloudfront. The React app is loaded from
# a url like: https://cdn.alpha.platform.smarter.sh/ui-prompt/index.html
reactjs_cdn_path = smarter_settings.smarter_reactjs_app_loader_path
reactjs_loader_url = smarter_settings.smarter_reactjs_app_loader_url
llm_client: Optional[LLMClient] = None
llm_client_helper: Optional[LLMClientHelper] = None
[docs]
def dispatch(self, request: HttpRequest, *args, **kwargs):
"""
Dispatch method to handle the request for the main prompt application page.
This method is responsible for preparing and serving the Django template that bootstraps the ReactJS prompt UI
within the Smarter dashboard web app. It injects the React loader script and configuration context into the
template, enabling seamless integration between Django and React.
**Key Features:**
- **Django Template Integration:**
Uses Django's template system to render the main prompt page, injecting the React app's loader script and root div.
- **ReactJS UI Bootstrapping:**
Loads the React build (JavaScript and CSS) from a CDN and injects it into the DOM. The React app then takes over
rendering the interactive prompt UI after the initial page load.
- **Flexible URL Patterns:**
Supports both sandbox and production URL formats, allowing the view to work with both deployed and not-yet-deployed LLMClients.
- **Authentication Protected:**
Requires the user to be authenticated. If the user is not authenticated, access is denied.
- **Cache Control:**
Uses Django's `never_cache` decorator to prevent the browser from caching the prompt page, ensuring session security.
Parameters
----------
request : HttpRequest
The incoming HTTP request object.
*args
Additional positional arguments.
**kwargs
Additional keyword arguments.
Returns
-------
HttpResponse
Renders the Django template for the prompt app, injecting the React loader and configuration context.
See Also
--------
PromptConfigView : The endpoint that provides configuration data to the React app.
"""
if not self.user_profile:
logger.error(
"%s.dispatch() - user_profile is None. This should not happen. Returning 403.",
self.formatted_class_name,
)
return SmarterHttpResponseForbidden(request=request, error_message="Authentication required")
retval = super().dispatch(request, *args, **kwargs)
if retval.status_code >= HTTPStatus.BAD_REQUEST:
return retval
session_key = kwargs.pop(SMARTER_CHAT_SESSION_KEY_NAME, None)
if session_key is not None:
self._session_key = session_key
verbose_logger.debug(
"%s.dispatch() - setting session_key=%s from kwargs",
self.formatted_class_name,
self.session_key,
)
try:
verbose_logger.debug(
"%s.dispatch() - url=%s, account=%s, user=%s",
self.formatted_class_name,
self.url,
self.account,
self.user_profile.user,
)
# first try to avoid some quite-expensive steps by looking for the llm_client
# in the cache based on the request.
self.llm_client = get_cached_llm_client_by_request(request=self.smarter_request)
if not self.llm_client:
self.llm_client_helper = LLMClientHelper(
request=self.smarter_request,
session_key=self.session_key,
account=self.account,
user=self.user,
user_profile=self.user_profile,
)
self.llm_client = self.llm_client_helper.llm_client if self.llm_client_helper.llm_client else None
if self.llm_client:
verbose_logger.debug(
"%s.dispatch() - set llm_client=%s from self.llm_client_helper",
self.formatted_class_name,
self.llm_client,
)
else:
logger.debug(
"%s.dispatch() - no llm_client found for request. Returning 404. Request URL: %s, Session Key: %s",
self.formatted_class_name,
request.build_absolute_uri(),
self.session_key,
)
return SmarterHttpResponseNotFound(request=request, error_message="LLMClient not found")
except LLMClient.DoesNotExist:
logger.debug(
"%s.dispatch() - LLMClient.DoesNotExist. No llm_client found for request. Returning 404. Request URL: %s, Session Key: %s",
self.formatted_class_name,
request.build_absolute_uri(),
self.session_key,
)
return SmarterHttpResponseNotFound(request=request, error_message="LLMClient not found")
# pylint: disable=broad-except
except Exception as e:
logger.error(
"%s.dispatch() - Exception occurred while getting llm_client: %s. "
"Request URL: %s, Session Key: %s\nStack trace: %s",
self.formatted_class_name,
str(e),
request.build_absolute_uri(),
self.session_key,
traceback.format_exc(),
)
return SmarterHttpResponseServerError(request=request, error_message=str(e))
if not self.llm_client:
logger.debug(
"%s.dispatch() - no llm_client found for request after exception handling. Returning 404. Request URL: %s, Session Key: %s",
self.formatted_class_name,
request.build_absolute_uri(),
self.session_key,
)
return SmarterHttpResponseNotFound(request=request, error_message="LLMClient not found")
# the basic idea is to pass the names of the necessary cookies to the React app, and then
# it is supposed to find and read the cookies to get the prompt session key, csrf token, etc.
context = {
"chatapp_workbench": {
"div_id": smarter_settings.smarter_reactjs_root_div_id,
"app_loader_url": self.reactjs_loader_url,
"llm_client_api_url": self.llm_client.sandbox_url,
"toggle_metadata": True,
"csrf_cookie_name": settings.CSRF_COOKIE_NAME,
"smarter_session_cookie_name": SMARTER_CHAT_SESSION_KEY_NAME, # this is the Smarter prompt session, not the Django session.
"django_session_cookie_name": settings.SESSION_COOKIE_NAME, # this is the Django session.
"cookie_domain": settings.SESSION_COOKIE_DOMAIN,
"debug_mode": waffle.switch_is_active(SmarterWaffleSwitches.ENABLE_REACTAPP_DEBUG_MODE),
}
}
verbose_logger.debug(
"%s.dispatch() - rendering template %s with context: %s",
self.formatted_class_name,
self.template_path,
formatted_json(context),
)
return render(request=request, template_name=self.template_path, context=context)