diff --git a/ai_chat/__init__.py b/ai_chat/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ai_chat/agents.py b/ai_chat/agents.py deleted file mode 100644 index 9f544dcb55..0000000000 --- a/ai_chat/agents.py +++ /dev/null @@ -1,603 +0,0 @@ -"""Agent service classes for the AI chatbots""" - -import json -import logging -from abc import ABC, abstractmethod -from typing import Optional - -import posthog -import pydantic -import requests -from django.conf import settings -from django.core.cache import caches -from django.urls import reverse -from django.utils.module_loading import import_string -from llama_index.agent.openai import OpenAIAgent -from llama_index.core.agent import AgentRunner -from llama_index.core.base.llms.types import ChatMessage -from llama_index.core.constants import DEFAULT_TEMPERATURE -from llama_index.core.tools import FunctionTool, ToolMetadata -from llama_index.llms.openai import OpenAI -from openai import BadRequestError -from pydantic import Field - -from ai_chat.constants import AIModelAPI -from ai_chat.utils import enum_zip -from learning_resources.constants import LearningResourceType, OfferedBy - -log = logging.getLogger(__name__) - - -class BaseChatAgent(ABC): - """ - Base service class for an AI chat agent - - Llamaindex was chosen to implement this because it provides - a far easier framework than native OpenAi or LiteLLM to - handle function calling completions. With LiteLLM/OpenAI, - the first response may or may not be the result of a - function call, so it's necessary to check the response. - If it did call a function, then a second completion is needed - to get the final response with the function call result added - to the chat history. Llamaindex handles this automatically. - - For comparison see: - https://docs.litellm.ai/docs/completion/function_call - """ - - INSTRUCTIONS = "Provide instructions for the AI assistant" - - # For LiteLLM tracking purposes - JOB_ID = "BASECHAT_JOB" - TASK_NAME = "BASECHAT_TASK" - - def __init__( # noqa: PLR0913 - self, - name: str, - *, - model: Optional[str] = None, - temperature: Optional[float] = None, - instructions: Optional[str] = None, - user_id: Optional[str] = None, - save_history: Optional[bool] = False, - cache_key: Optional[str] = None, - cache_timeout: Optional[int] = None, - collection_name: Optional[str] = None, - ): - """Initialize the AI chat agent service""" - self.assistant_name = name - self.ai = settings.AI_MODEL_API - self.model = model or settings.AI_MODEL - self.save_history = save_history - self.temperature = temperature or DEFAULT_TEMPERATURE - self.instructions = instructions or self.INSTRUCTIONS - self.collection_name = collection_name - self.user_id = user_id - if settings.AI_PROXY_CLASS: - self.proxy = import_string(f"ai_chat.proxy.{settings.AI_PROXY_CLASS}")() - else: - self.proxy = None - if save_history: - if not cache_key: - msg = "cache_key must be set to save chat history" - raise ValueError(msg) - self.cache = caches[settings.AI_CACHE] - self.cache_timeout = cache_timeout or settings.AI_CACHE_TIMEOUT - self.cache_key = cache_key - else: - self.cache = None - self.cache_timeout = None - self.cache_key = "" - self.agent = None - - def get_or_create_chat_history_cache(self, agent: AgentRunner) -> None: - """ - Get the user chat history from the cache and load it into the - llamaindex agent's chat history (agent.chat_history). - Create an empty cache key if it doesn't exist. - """ - if self.cache_key in self.cache: - try: - for message in json.loads(self.cache.get(self.cache_key)): - agent.chat_history.append(ChatMessage(**message)) - except json.JSONDecodeError: - self.cache.set(self.cache_key, "[]", timeout=self.cache_timeout) - else: - if self.proxy: - self.proxy.create_proxy_user(self.user_id) - self.cache.set(self.cache_key, "[]", timeout=self.cache_timeout) - - def create_agent(self) -> AgentRunner: - """Create an AgentRunner for the relevant AI source""" - if self.ai == AIModelAPI.openai.value: - return self.create_openai_agent() - else: - error = f"AI source {self.ai} is not supported" - raise NotImplementedError(error) - - def create_tools(self): - """Create any tools required by the agent""" - return [] - - @abstractmethod - def create_openai_agent(self) -> OpenAIAgent: - """Create an OpenAI agent""" - - def save_chat_history(self) -> None: - """Save the agent chat history to the cache""" - chat_history = [ - message.dict() - for message in self.agent.chat_history - if message.role != "tool" and message.content - ] - self.cache.set(self.cache_key, json.dumps(chat_history), timeout=3600) - - def clear_chat_history(self) -> None: - """Clear the chat history from the cache""" - if self.save_history: - self.agent.chat_history.clear() - self.cache.delete(self.cache_key) - self.get_or_create_chat_history_cache(self.agent) - - @abstractmethod - def get_comment_metadata(self): - """Yield markdown comments to send hidden metdata in the response""" - - def get_completion(self, message: str, *, debug: bool = settings.AI_DEBUG) -> str: - """ - Send the user message to the agent and yield the response as - it comes in. - - Append the response with debugging metadata and/or errors. - """ - full_response = "" - if not self.agent: - error = "Create agent before running" - raise ValueError(error) - try: - response = self.agent.stream_chat( - message, - ) - response_gen = response.response_gen - for response in response_gen: - full_response += response - yield response - except BadRequestError as error: - # Format and yield an error message inside a hidden comment - if hasattr(error, "response"): - error = error.response.json() - else: - error = { - "error": {"message": "An error has occurred, please try again"} - } - if ( - error["error"]["message"].startswith("Budget has been exceeded") - and not settings.AI_DEBUG - ): # Friendlier message for end user - error["error"]["message"] = ( - "You have exceeded your AI usage limit. Please try again later." - ) - yield f"".encode() - except Exception: - yield '' - log.exception("Error running AI agent") - if self.save_history: - self.save_chat_history() - if debug: - yield f"\n\n\n\n".encode() - hog_client = posthog.Posthog( - settings.POSTHOG_PROJECT_API_KEY, host=settings.POSTHOG_API_HOST - ) - hog_client.capture( - self.user_id, - event=self.JOB_ID, - properties={ - "question": message, - "answer": full_response, - "metadata": self.get_comment_metadata(), - "user": self.user_id, - }, - ) - - -class SearchAgent(BaseChatAgent): - """Service class for the AI search function agent""" - - JOB_ID = "recommendation_agent" - TASK_NAME = "recommendation_task" - - INSTRUCTIONS = f"""You are an assistant helping users find courses from a catalog -of learning resources. Users can ask about specific topics, levels, or recommendations -based on their interests or goals. - -Your job: -1. Understand the user's intent AND BACKGROUND based on their message. -2. Use the available function to gather information or recommend courses. -3. Provide a clear, user-friendly explanation of your recommendations if search results -are found. - - -Always run the tool to answer questions, and answer only based on the function search -results. VERY IMPORTANT: NEVER USE ANY INFORMATION OUTSIDE OF THE MIT SEARCH RESULTS TO -ANSWER QUESTIONS. If no results are returned, say you could not find any relevant -resources. Don't say you're going to try again. Ask the user if they would like to -modify their preferences or ask a different question. - -Here are some guidelines on when to use the possible filters in the search function: - -q: The area of interest requested by the user. NEVER INCLUDE WORDS SUCH AS "advanced" -or "introductory" IN THIS PARAMETER! If the user asks for introductory, intermediate, -or advanced courses, do not include that in the search query, but examine the search -results to determine which most closely match the user's desired education level and/or -their educational background (if either is provided) and choose those results to return -to the user. If the user asks what other courses are taught by a particular instructor, -search the catalog for courses taught by that instructor using the instructor's name -as the value for this parameter. - -offered_by: If a user asks for resources "offered by" or "from" an institution, -you should include this parameter based on the following -dictionary: {OfferedBy.as_dict()} DO NOT USE THE offered_by FILTER OTHERWISE. - -certification: true if the user is interested in resources that offer certificates, -false if the user does not want resources with a certificate offered. Do not use -this filter if the user does not indicate a preference. - -free: true if the user is interested in free resources, false if the user is only -interested in paid resources. Do not used this filter if the user does not indicate -a preference. - -resource_type: If the user mentions courses, programs, videos, or podcasts in -particular, filter the search by this parameter. DO NOT USE THE resource_type FILTER -OTHERWISE. You MUST combine multiple resource types in one request like this: -"resource_type=course&resource_type=program". Do not attempt more than one query per -user message. If the user asks for podcasts, filter by both "podcast" and -"podcast_episode". - -Respond in this format: -- If the user's intent is unclear, ask clarifying questions about users preference on -price, certificate -- Understand user background from the message history, like their level of education. -- After the function executes, rerank results based on user background and recommend -1 or 2 courses to the user -- Make the title of each resource a clickable link. - -VERY IMPORTANT: NEVER USE ANY INFORMATION OUTSIDE OF THE MIT SEARCH RESULTS TO ANSWER -QUESTIONS. - -Here are some sample user prompts, each with a guide on how to respond to them: - -Prompt: “I\'d like to learn some advanced mathematics that I may not have had exposure -to before, as a physics major.” -Expected Response: Ask some follow-ups about particular interests (e.g., set theory, -analysis, topology. Maybe ask whether you are more interested in applied math or theory. -Then perform the search based on those interests and send the most relevant results back -based on the user's answers. - -Prompt: “As someone with a non-science background, what courses can I take that will -prepare me for the AI future.” -Expected Output: Maybe ask whether the user wants to learn how to program, or just use -AI in their discipline - does this person want to study machine learning? More info -needed. Then perform a relevant search and send back the best results. - -And here are some recommended search parameters to apply for sample user prompts: - -User: "I am interested in learning advanced AI techniques" -Search parameters: {{"q": "AI techniques"}} - -User: "I am curious about AI applications for business" -Search parameters: {{"q": "AI business"}} - -User: "I want free basic courses about climate change from OpenCourseware" -Search parameters: {{"q": "climate change", "free": true, "resource_type": ["course"], -"offered_by": "ocw"}} - -User: "I want to learn some advanced mathematics" -Search parameters: {{"q": "mathematics"}} - """ - - class SearchToolSchema(pydantic.BaseModel): - """Schema for searching MIT learning resources. - - Attributes: - q: The search query string - resource_type: Filter by type of resource (course, program, etc) - free: Filter for free resources only - certification: Filter for resources offering certificates - offered_by: Filter by institution offering the resource - """ - - q: str = Field( - description=( - "Query to find resources. Never use level terms like 'advanced' here" - ) - ) - resource_type: Optional[ - list[enum_zip("resource_type", LearningResourceType)] - ] = Field( - default=None, - description="Type of resource to search for: course, program, video, etc", - ) - free: Optional[bool] = Field( - default=None, - description="Whether the resource is free to access, true|false", - ) - certification: Optional[bool] = Field( - default=None, - description=( - "Whether the resource offers a certificate upon completion, true|false" - ), - ) - offered_by: Optional[enum_zip("offered_by", OfferedBy)] = Field( - default=None, - description="Institution that offers the resource: ocw, mitxonline, etc", - ) - - model_config = { - "json_schema_extra": { - "examples": [ - { - "q": "machine learning", - "resource_type": ["course"], - "free": True, - "certification": False, - "offered_by": "MIT", - } - ] - } - } - - def __init__( # noqa: PLR0913 - self, - name: str, - *, - model: Optional[str] = None, - temperature: Optional[float] = None, - instructions: Optional[str] = None, - user_id: Optional[str] = None, - save_history: Optional[bool] = False, - cache_key: Optional[str] = None, - cache_timeout: Optional[int] = None, - collection_name: Optional[str] = None, - ): - """Initialize the AI search agent service""" - super().__init__( - name, - model=model or settings.AI_MODEL, - temperature=temperature, - instructions=instructions, - save_history=save_history, - user_id=user_id, - cache_key=cache_key, - cache_timeout=cache_timeout or settings.AI_CACHE_TIMEOUT, - collection_name=collection_name, - ) - self.search_parameters = [] - self.search_results = [] - self.agent = self.create_agent() - self.create_agent() - - def search_courses(self, q: str, **kwargs) -> str: - """ - Query the MIT API for learning resources, and - return simplified results as a JSON string - """ - - params = {"q": q, "limit": settings.AI_MIT_SEARCH_LIMIT} - - valid_params = { - "resource_type": kwargs.get("resource_type"), - "free": kwargs.get("free"), - "offered_by": kwargs.get("offered_by"), - "certification": kwargs.get("certification"), - } - params.update({k: v for k, v in valid_params.items() if v is not None}) - self.search_parameters.append(params) - try: - response = requests.get( - settings.AI_MIT_SEARCH_URL, params=params, timeout=30 - ) - response.raise_for_status() - raw_results = response.json().get("results", []) - # Simplify the response to only include the main properties - main_properties = [ - "title", - "url", - "description", - "offered_by", - "free", - "certification", - "resource_type", - ] - simplified_results = [] - for result in raw_results: - simplified_result = {k: result.get(k) for k in main_properties} - # Instructors and level will be in the runs data if present - next_date = result.get("next_start_date", None) - raw_runs = result.get("runs", []) - best_run = None - if next_date: - runs = [run for run in raw_runs if run["start_date"] == next_date] - if runs: - best_run = runs[0] - elif raw_runs: - best_run = raw_runs[-1] - if best_run: - for attribute in ("level", "instructors"): - simplified_result[attribute] = best_run.get(attribute, []) - simplified_results.append(simplified_result) - self.search_results.extend(simplified_results) - return json.dumps(simplified_results) - except requests.exceptions.RequestException: - log.exception("Error querying MIT API") - return json.dumps({"error": "An error occurred while searching"}) - - def create_openai_agent(self) -> OpenAIAgent: - """ - Create an OpenAI-specific llamaindex agent for function calling - - Using `OpenAI` instead of a more universal `LiteLLM` because - the `LiteLLM` class as implemented by llamaindex does not - support function calling. ie: - agent = FunctionCallingAgentWorker.from_tools(.... - > AssertionError: llm must be an instance of FunctionCallingLLM - """ - llm = OpenAI( - model=self.model, - **(self.proxy.get_api_kwargs() if self.proxy else {}), - additional_kwargs=( - self.proxy.get_additional_kwargs(self) if self.proxy else {} - ), - ) - if self.temperature: - llm.temperature = self.temperature - agent = OpenAIAgent.from_tools( - tools=self.create_tools(), - llm=llm, - verbose=True, - system_prompt=self.instructions, - ) - if self.save_history: - self.get_or_create_chat_history_cache(agent) - return agent - - def create_tools(self): - """Create tools required by the agent""" - return [self.create_search_tool()] - - def create_search_tool(self) -> FunctionTool: - """Create the search tool for the AI agent""" - metadata = ToolMetadata( - name="search_courses", - description="Search for learning resources in the MIT catalog", - fn_schema=self.SearchToolSchema, - ) - return FunctionTool.from_defaults( - fn=self.search_courses, tool_metadata=metadata - ) - - def get_comment_metadata(self) -> str: - """ - Yield markdown comments to send hidden metadata in the response - """ - metadata = { - "metadata": { - "search_parameters": self.search_parameters, - "search_results": self.search_results, - } - } - return json.dumps(metadata) - - -class SyllabusAgent(SearchAgent): - """Service class for the AI syllabus agent""" - - JOB_ID = "syllabus_agent" - TASK_NAME = "syllabus_task" - - INSTRUCTIONS = """You are an assistant helping users answer questions related -to a syllabus. - -Your job: -1. Use the available function to gather relevant information about the user's question. -2. Provide a clear, user-friendly summary of the information retrieved by the tool to -answer the user's question. - -The tool knows which course the user is asking about, so you don't need to ask for it. - -Always run the tool to answer questions, and answer only based on the tool -output. VERY IMPORTANT: NEVER USE ANY INFORMATION OUTSIDE OF THE TOOL OUTPUT TO -ANSWER QUESTIONS. If no results are returned, say you could not find any relevant -information. - """ - - class SyllabusToolSchema(pydantic.BaseModel): - """Schema for searching MIT contentfile chunks.""" - - def __init__( # noqa: PLR0913 - self, - name: str, - *, - model: Optional[str] = None, - temperature: Optional[float] = None, - instructions: Optional[str] = None, - user_id: Optional[str] = None, - save_history: Optional[bool] = False, - cache_key: Optional[str] = None, - cache_timeout: Optional[int] = None, - collection_name: Optional[str] = None, - ): - """Initialize the AI search agent service""" - super().__init__( - name, - model=model or settings.AI_MODEL, - temperature=temperature, - instructions=instructions, - save_history=save_history, - user_id=user_id, - cache_key=cache_key, - cache_timeout=cache_timeout or settings.AI_CACHE_TIMEOUT, - collection_name=collection_name, - ) - self.search_parameters = [] - self.search_results = [] - self.collection_name = collection_name - self.agent = self.create_agent() - self.create_agent() - - def search_content_files(self) -> str: - """ - Query the MIT contentfile chunks API, and - return results as a JSON string - """ - url = settings.AI_MIT_SYLLABUS_URL or reverse( - "vector_search:v0:vector_content_files_search" - ) - params = { - "q": self.user_message, - "resource_readable_id": self.readable_id, - "limit": 20, - } - if self.collection_name: - params["collection_name"] = self.collection_name - self.search_parameters.append(params) - try: - response = requests.get(url, params=params, timeout=30) - response.raise_for_status() - raw_results = response.json().get("results", []) - # Simplify the response to only include the main properties - simplified_results = [] - for result in raw_results: - simplified_result = {"chunk_content": result.get("chunk_content")} - simplified_results.append(simplified_result) - self.search_results.extend(simplified_results) - return json.dumps(simplified_results) - except requests.exceptions.RequestException: - log.exception("Error querying MIT API") - return json.dumps({"error": "An error occurred while searching"}) - - def create_tools(self): - """Create tools required by the agent""" - return [self.create_search_tool()] - - def create_search_tool(self) -> FunctionTool: - """Create the search tool for the AI agent""" - metadata = ToolMetadata( - name="search_content_files", - description="Search for learning resources in the MIT catalog", - fn_schema=self.SyllabusToolSchema, - ) - return FunctionTool.from_defaults( - fn=self.search_content_files, tool_metadata=metadata - ) - - def get_completion( - self, message: str, readable_id: str, *, debug: bool = settings.AI_DEBUG - ) -> str: - """ - Get a response to the user's message. Use the exact user message as the - q parameter value for the vector search. - """ - self.user_message = message - self.readable_id = readable_id - historical_message = f"{message}\n\ncourse readable_id: {readable_id}" - return super().get_completion(historical_message, debug=debug) diff --git a/ai_chat/agents_test.py b/ai_chat/agents_test.py deleted file mode 100644 index 605d05cd3f..0000000000 --- a/ai_chat/agents_test.py +++ /dev/null @@ -1,294 +0,0 @@ -"""Tests for AI agent services.""" - -import json - -import pytest -from django.conf import settings -from django.core.cache import caches -from llama_index.core.base.llms.types import MessageRole -from llama_index.core.constants import DEFAULT_TEMPERATURE - -from ai_chat.agents import SearchAgent, SyllabusAgent -from ai_chat.factories import ChatMessageFactory -from learning_resources.factories import LearningResourceFactory -from learning_resources.serializers import ( - CourseResourceSerializer, -) -from main.factories import UserFactory -from main.test_utils import assert_json_equal - - -@pytest.fixture(autouse=True) -def ai_settings(settings): - """Set the AI settings for the tests.""" - settings.AI_CACHE = "default" - settings.AI_PROXY_URL = "" - return settings - - -@pytest.fixture -def chat_history(): - """Return one round trip chat history for testing.""" - return [ - ChatMessageFactory(role=MessageRole.USER), - ChatMessageFactory(role=MessageRole.ASSISTANT), - ] - - -@pytest.mark.parametrize( - ("model", "temperature", "instructions"), - [ - ("gpt-3.5-turbo", 0.1, "Answer this question as best you can"), - ("gpt-4o", 0.3, None), - ("gpt-4", None, None), - (None, None, None), - ], -) -def test_search_agent_service_initialization_defaults(model, temperature, instructions): - """Test the SearchAgent class instantiation.""" - name = "My search agent" - user_id = "testuser@test.edu" - - search_agent = SearchAgent( - name, - model=model, - temperature=temperature, - instructions=instructions, - user_id=user_id, - ) - assert search_agent.model == (model if model else settings.AI_MODEL) - assert search_agent.temperature == ( - temperature if temperature else DEFAULT_TEMPERATURE - ) - assert search_agent.instructions == ( - instructions if instructions else search_agent.instructions - ) - assert search_agent.agent.__class__.__name__ == "OpenAIAgent" - assert search_agent.agent.agent_worker._llm.model == ( # noqa: SLF001 - model if model else settings.AI_MODEL - ) - - -@pytest.mark.parametrize( - ("cache_key", "cache_timeout", "save_history"), - [ - ("test_cache_key", 60, True), - ("test_cache_key", 60, False), - (None, 60, True), - (None, 60, False), - ("test_cache_key", None, True), - ("test_cache_key", None, False), - ], -) -def test_search_agent_service_chat_history_settings( - cache_key, cache_timeout, save_history -): - """Test that the SearchAgent chat history settings are set correctly.""" - if save_history and not cache_key: - with pytest.raises( - ValueError, match="cache_key must be set to save chat history" - ): - SearchAgent( - "test agent", - cache_key=cache_key, - cache_timeout=cache_timeout, - save_history=save_history, - ) - else: - service = SearchAgent( - "test agent", - cache_key=cache_key, - cache_timeout=cache_timeout, - save_history=save_history, - ) - assert service.cache_key == (cache_key if save_history else "") - assert service.cache_timeout == ( - (cache_timeout if cache_timeout else settings.AI_CACHE_TIMEOUT) - if save_history - else None - ) - - -def test_get_or_create_chat_history_cache(settings, user, chat_history): - """Test that the SearchAgent get_or_create_chat_history_cache method works.""" - - caches[settings.AI_CACHE].set( - f"{user.email}_test_cache_key", - json.dumps([message.model_dump() for message in chat_history]), - ) - user_service = SearchAgent( - "test agent", - user_id=user.email, - cache_key=f"{user.email}_test_cache_key", - save_history=True, - ) - assert [ - (message.role, message.content) for message in user_service.agent.chat_history - ] == [(message.role, message.content) for message in chat_history] - - # Different user should have different chat history - user2 = UserFactory.create() - user2_service = SearchAgent( - "test agent", - user_id=user2.email, - cache_key=f"{user2.email}_test_cache_key", - save_history=True, - ) - assert user2_service.agent.chat_history == [] - - # Same user different cache should have different chat history - user_service2 = SearchAgent( - "test agent", - user_id=user.email, - cache_key=f"{user.email}_other_cache_key", - save_history=True, - ) - assert user_service2.agent.chat_history == [] - - # Chat history should be cleared out if requested - assert len(user_service.agent.chat_history) == 2 - user_service.clear_chat_history() - assert user_service.agent.chat_history == [] - assert caches[settings.AI_CACHE].get(f"{user.email}_test_cache_key") == "[]" - - -def test_clear_chat_history(client, user, chat_history): - """Test that the SearchAgent clears chat_history.""" - - caches[settings.AI_CACHE].set( - f"{user.email}_test_cache_key", - json.dumps([message.model_dump() for message in chat_history]), - ) - search_agent = SearchAgent( - "test agent", - user_id=user.email, - cache_key=f"{user.email}_test_cache_key", - save_history=True, - ) - assert len(search_agent.agent.chat_history) == 2 - assert ( - len(json.loads(caches[settings.AI_CACHE].get(f"{user.email}_test_cache_key"))) - == 2 - ) - - search_agent.clear_chat_history() - assert search_agent.agent.chat_history == [] - assert caches[settings.AI_CACHE].get(f"{user.email}_test_cache_key") == "[]" - - -@pytest.mark.django_db -def test_search_agent_tool(settings, mocker): - """The search agent tool should be created and function correctly.""" - settings.AI_MIT_SEARCH_LIMIT = 5 - retained_attributes = [ - "title", - "url", - "description", - "offered_by", - "free", - "certification", - "resource_type", - ] - raw_results = [ - CourseResourceSerializer(resource).data - for resource in LearningResourceFactory.create_batch(5) - ] - expected_results = [ - {key: resource.get("key") for key in retained_attributes} - for resource in raw_results - ] - mock_post = mocker.patch( - "ai_chat.agents.requests.get", - return_value=mocker.Mock( - json=mocker.Mock(return_value={"results": expected_results}) - ), - ) - search_agent = SearchAgent("test agent") - search_parameters = { - "q": "physics", - "resource_type": ["course", "program"], - "free": False, - "certification": True, - "offered_by": "xpro", - "limit": 5, - } - tool = search_agent.create_tools()[0] - results = tool._fn(**search_parameters) # noqa: SLF001 - mock_post.assert_called_once_with( - settings.AI_MIT_SEARCH_URL, params=search_parameters, timeout=30 - ) - assert_json_equal(json.loads(results), expected_results) - - -@pytest.mark.django_db -def test_get_completion(mocker): - """Test that the SearchAgent get_completion method returns expected values.""" - metadata = { - "metadata": { - "search_parameters": {"q": "physics"}, - "search_results": [ - CourseResourceSerializer(resource).data - for resource in LearningResourceFactory.create_batch(5) - ], - "system_prompt": SearchAgent.INSTRUCTIONS, - } - } - expected_return_value = ["Here ", "are ", "some ", "results"] - mocker.patch( - "ai_chat.agents.OpenAIAgent.stream_chat", - return_value=mocker.Mock(response_gen=iter(expected_return_value)), - ) - mock_hog = mocker.patch("ai_chat.agents.posthog.Posthog") - search_agent = SearchAgent("test agent", user_id="testuser@email.edu") - search_agent.search_parameters = metadata["metadata"]["search_parameters"] - search_agent.search_results = metadata["metadata"]["search_results"] - search_agent.instructions = metadata["metadata"]["system_prompt"] - search_agent.search_parameters = {"q": "physics"} - search_agent.search_results = [ - CourseResourceSerializer(resource).data - for resource in LearningResourceFactory.create_batch(5) - ] - results = "".join( - [ - str(chunk) - for chunk in search_agent.get_completion( - "I want to learn physics", - ) - ] - ) - search_agent.agent.stream_chat.assert_called_once_with("I want to learn physics") - mock_hog.return_value.capture.assert_called_once_with( - "testuser@email.edu", - event=search_agent.JOB_ID, - properties={ - "question": "I want to learn physics", - "answer": "Here are some results", - "metadata": search_agent.get_comment_metadata(), - "user": "testuser@email.edu", - }, - ) - assert "".join([str(value) for value in expected_return_value]) in results - - -@pytest.mark.django_db -def test_collection_name_param(settings, mocker): - """The collection name should be passed through to the contentfile search""" - settings.AI_MIT_SEARCH_LIMIT = 5 - settings.AI_MIT_SYLLABUS_URL = "https://test.com/api/v0/contentfiles/" - mock_post = mocker.patch( - "ai_chat.agents.requests.get", - return_value=mocker.Mock(json=mocker.Mock(return_value={})), - ) - search_agent = SyllabusAgent("test agent", collection_name="content_files_512") - search_agent.get_completion("I want to learn physics", readable_id="test") - search_agent.search_content_files() - mock_post.assert_called_once_with( - settings.AI_MIT_SYLLABUS_URL, - params={ - "q": "I want to learn physics", - "resource_readable_id": "test", - "limit": 20, - "collection_name": "content_files_512", - }, - timeout=30, - ) diff --git a/ai_chat/apps.py b/ai_chat/apps.py deleted file mode 100644 index 32b00f07a2..0000000000 --- a/ai_chat/apps.py +++ /dev/null @@ -1,9 +0,0 @@ -"""ai_chat app config""" - -from django.apps import AppConfig - - -class AiChatConfig(AppConfig): - """AI Chat Appconfig""" - - name = "ai_chat" diff --git a/ai_chat/conftest.py b/ai_chat/conftest.py deleted file mode 100644 index 8f4bf20cda..0000000000 --- a/ai_chat/conftest.py +++ /dev/null @@ -1,10 +0,0 @@ -import pytest - - -@pytest.fixture -def mock_search_agent_service(mocker): - """Mock the SearchAgentService class.""" - return mocker.patch( - "ai_chat.views.SearchAgentService", - autospec=True, - ) diff --git a/ai_chat/constants.py b/ai_chat/constants.py deleted file mode 100644 index 311ae33a77..0000000000 --- a/ai_chat/constants.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Constants for the AI Chat application.""" - -from named_enum import ExtendedEnum - - -class AIModelAPI(ExtendedEnum): - """ - Enum for AI model APIs. Add new AI APIs here. - """ - - openai = "openai" - - -GROUP_STAFF_AI_SYTEM_PROMPT_EDITORS = "ai_system_prompt_editors" -AI_ANONYMOUS_USER = "anonymous" diff --git a/ai_chat/factories.py b/ai_chat/factories.py deleted file mode 100644 index 383497950e..0000000000 --- a/ai_chat/factories.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Test factory classes for ai_chat tests""" - -import factory -from factory.fuzzy import FuzzyChoice -from llama_index.core.base.llms.types import MessageRole -from llama_index.core.llms import ChatMessage - - -class ChatMessageFactory(factory.Factory): - """Factory for generating llamaindex ChatMessage instances.""" - - role = FuzzyChoice(MessageRole.USER, MessageRole.ASSISTANT) - content = factory.Faker("sentence") - id = name = factory.Sequence(lambda n: str(n)) - index = factory.Sequence(lambda n: str(n)) - - class Meta: - model = ChatMessage diff --git a/ai_chat/migrations/0001_initial.py b/ai_chat/migrations/0001_initial.py deleted file mode 100644 index 5d1ff2678d..0000000000 --- a/ai_chat/migrations/0001_initial.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 3.1 on 2021-01-28 16:27 -from django.conf import settings -from django.contrib.auth.models import Group -from django.db import migrations - -from ai_chat import constants - - -def add_ai_prompt_editor_group(apps, schema_editor): - """ - Create the staff list editors group - """ - Group.objects.get_or_create(name=constants.GROUP_STAFF_AI_SYTEM_PROMPT_EDITORS) - - -def remove_ai_prompt_editor_group(apps, schema_editor): - """ - Delete the staff list editors group - """ - Group.objects.filter(name=constants.GROUP_STAFF_AI_SYTEM_PROMPT_EDITORS).delete() - - -class Migration(migrations.Migration): - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RunPython(add_ai_prompt_editor_group, remove_ai_prompt_editor_group), - ] diff --git a/ai_chat/migrations/__init__.py b/ai_chat/migrations/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ai_chat/permissions.py b/ai_chat/permissions.py deleted file mode 100644 index bcf37c213b..0000000000 --- a/ai_chat/permissions.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Permissions for ai chat agents""" - -from rest_framework import permissions - -from main import features - - -class SearchAgentPermissions(permissions.BasePermission): - """Permissions for ai search service""" - - def has_permission(self, request, view): # noqa: ARG002 - """Check if the user has permission""" - return features.is_enabled("recommendation-bot") diff --git a/ai_chat/proxy.py b/ai_chat/proxy.py deleted file mode 100644 index 67b921e5d7..0000000000 --- a/ai_chat/proxy.py +++ /dev/null @@ -1,111 +0,0 @@ -"""AI proxy helper classes""" - -import logging -from abc import ABC, abstractmethod -from urllib.parse import urljoin - -import requests -from django.conf import settings - -from ai_chat.agents import BaseChatAgent -from ai_chat.constants import AI_ANONYMOUS_USER - -log = logging.getLogger(__name__) - - -class AIProxy(ABC): - """Abstract base helper class for an AI proxy/gateway.""" - - REQUIRED_SETTINGS = [] - - def __init__(self): - """Raise an error if required settings are missing.""" - missing_settings = [ - setting - for setting in self.REQUIRED_SETTINGS - if not getattr(settings, setting, None) - ] - if missing_settings: - message = ",".join(missing_settings) - raise ValueError(message) - - @abstractmethod - def get_api_kwargs(self) -> dict: - """Get the api kwargs required to connect to the proxy.""" - - @abstractmethod - def get_additional_kwargs(self, service: BaseChatAgent) -> dict: - """Get any additional kwargs that should be sent to the proxy""" - - @abstractmethod - def create_proxy_user(self, endpoint: str) -> None: - """Create a proxy user.""" - - -class LiteLLMProxy(AIProxy): - """Helper class for the Lite LLM proxy.""" - - REQUIRED_SETTINGS = ("AI_PROXY_URL", "AI_PROXY_AUTH_TOKEN") - - def get_api_kwargs(self) -> dict: - return { - "api_base": settings.AI_PROXY_URL, - "api_key": settings.AI_PROXY_AUTH_TOKEN, - } - - def get_additional_kwargs(self, service: BaseChatAgent) -> dict: - return { - "user": service.user_id, - "store": True, - "extra_body": { - "metadata": { - "tags": [ - f"jobID:{service.JOB_ID}", - f"taskName:{service.TASK_NAME}", - ] - } - }, - } - - def create_proxy_user(self, user_id, endpoint="new") -> None: - """ - Set the rate limit for the user by creating a LiteLLM customer account. - Anonymous users will share the same rate limit. - """ - if settings.AI_PROXY_URL and settings.AI_PROXY_AUTH_TOKEN: - # Unauthenticated users will share a common budget/rate limit, - # so multiply for some extra capacity - multiplier = ( - settings.AI_ANON_LIMIT_MULTIPLIER if user_id == AI_ANONYMOUS_USER else 1 - ) - request_json = { - "user_id": user_id, - "max_parallel_requests": settings.AI_MAX_PARALLEL_REQUESTS * multiplier, - "tpm_limit": settings.AI_TPM_LIMIT * multiplier, - "rpm_limit": settings.AI_RPM_LIMIT * multiplier, - "max_budget": settings.AI_MAX_BUDGET * multiplier, - "budget_duration": settings.AI_BUDGET_DURATION, - } - headers = {"Authorization": f"Bearer {settings.AI_PROXY_AUTH_TOKEN}"} - try: - response = requests.post( - urljoin(settings.AI_PROXY_URL, f"/customer/{endpoint}"), - json=request_json, - timeout=settings.REQUESTS_TIMEOUT, - headers=headers, - ) - response.raise_for_status() - except Exception as error: - if "duplicate key value violates unique constraint" in str(error): - """ - Try updating the LiteLLM customer account if it already exists. - Unfortunately, LiteLLM seems to have a bug that prevents - updates to the customer's max_budget: - https://github.com/BerriAI/litellm/issues/6920 - - We could create LiteLLM internal user accounts instead, but that - would require saving and using the LiteLLM keys generated per user. - """ - self.create_proxy_user(user_id=user_id, endpoint="update") - else: - log.exception("Error creating/updating proxy customer account") diff --git a/ai_chat/serializers.py b/ai_chat/serializers.py deleted file mode 100644 index 0f0e569773..0000000000 --- a/ai_chat/serializers.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Serializers for the ai_chat app""" - -from django.conf import settings -from rest_framework import serializers - -from ai_chat.constants import GROUP_STAFF_AI_SYTEM_PROMPT_EDITORS - - -class ChatRequestSerializer(serializers.Serializer): - """DRF serializer for chatbot requests""" - - message = serializers.CharField(allow_blank=False) - model = serializers.CharField(default=settings.AI_MODEL, required=False) - temperature = serializers.FloatField(min_value=0.0, max_value=1.0, required=False) - instructions = serializers.CharField(required=False) - clear_history = serializers.BooleanField(default=False) - - def validate_instructions(self, value): - """Check if the user is allowed to modify the AI system prompt""" - if value: - request = self.context.get("request") - user = request.user - if settings.ENVIRONMENT == "dev" or ( - user - and user.is_authenticated - and ( - user.is_superuser - or user.groups.filter( - name=GROUP_STAFF_AI_SYTEM_PROMPT_EDITORS - ).first() - is not None - ) - ): - return value - msg = "You are not allowed to modify the AI system prompt." - raise serializers.ValidationError(msg) - return value - - -class SyllabusChatRequestSerializer(ChatRequestSerializer): - """DRF serializer for syllabus chatbot requests""" - - readable_id = serializers.CharField(required=True) - collection_name = serializers.CharField(required=False) diff --git a/ai_chat/urls.py b/ai_chat/urls.py deleted file mode 100644 index 74eb0b785f..0000000000 --- a/ai_chat/urls.py +++ /dev/null @@ -1,19 +0,0 @@ -"""URLs for the ai_chat app.""" - -from django.urls import include, re_path - -from ai_chat import views - -app_name = "ai_chat" - -v0_urls = [ - re_path(r"^chat_agent/", views.SearchAgentView.as_view(), name="chatbot_agent_api"), - re_path( - r"^syllabus_agent/", - views.SyllabusAgentView.as_view(), - name="syllabus_agent_api", - ), -] -urlpatterns = [ - re_path(r"^api/v0/", include((v0_urls, "v0"))), -] diff --git a/ai_chat/utils.py b/ai_chat/utils.py deleted file mode 100644 index 4a8001041e..0000000000 --- a/ai_chat/utils.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Utility functions for ai chat agents""" - -import logging -from enum import Enum - -log = logging.getLogger(__name__) - - -def enum_zip(label: str, enum: Enum) -> type[Enum]: - """Create a new Enum from a tuple of Enum names""" - return Enum(label, dict(zip(enum.names(), enum.names()))) diff --git a/ai_chat/views.py b/ai_chat/views.py deleted file mode 100644 index 9ac6b86d37..0000000000 --- a/ai_chat/views.py +++ /dev/null @@ -1,123 +0,0 @@ -"""DRF views for the ai_chat app.""" - -import logging - -from django.http import StreamingHttpResponse -from drf_spectacular.utils import extend_schema -from rest_framework import views -from rest_framework.request import Request - -from ai_chat import serializers -from ai_chat.permissions import SearchAgentPermissions - -log = logging.getLogger(__name__) - - -class SearchAgentView(views.APIView): - """ - DRF view for an AI agent that answers user queries - by performing a relevant learning resources search. - """ - - http_method_names = ["post"] - serializer_class = serializers.ChatRequestSerializer - permission_classes = (SearchAgentPermissions,) # Add IsAuthenticated - - @extend_schema( - responses={ - (200, "text/event-stream"): { - "description": "Chatbot response", - "type": "string", - } - } - ) - def post(self, request: Request) -> StreamingHttpResponse: - """Handle a POST request to the chatbot agent.""" - from ai_chat.agents import SearchAgent - - serializer = serializers.ChatRequestSerializer( - data=request.data, context={"request": request} - ) - serializer.is_valid(raise_exception=True) - if not request.session.session_key: - request.session.save() - cache_id = ( - request.user.email - if request.user.is_authenticated - else request.session.session_key - ) - # Make anonymous users share a common LiteLLM budget/rate limit. - user_id = request.user.email if request.user.is_authenticated else "anonymous" - message = serializer.validated_data.pop("message", "") - clear_history = serializer.validated_data.pop("clear_history", False) - agent = SearchAgent( - "Learning Resource Search AI Assistant", - user_id=user_id, - cache_key=f"{cache_id}_search_chat_history", - save_history=True, - **serializer.validated_data, - ) - if clear_history: - agent.clear_chat_history() - return StreamingHttpResponse( - agent.get_completion(message), - content_type="text/event-stream", - headers={"X-Accel-Buffering": "no"}, - ) - - -class SyllabusAgentView(views.APIView): - """ - DRF view for an AI agent that answers user queries - by performing a relevant contentfile search for a - specified course. - """ - - http_method_names = ["post"] - serializer_class = serializers.SyllabusChatRequestSerializer - permission_classes = (SearchAgentPermissions,) # Add IsAuthenticated - - @extend_schema( - responses={ - (200, "text/event-stream"): { - "description": "Chatbot response", - "type": "string", - } - } - ) - def post(self, request: Request) -> StreamingHttpResponse: - """Handle a POST request to the chatbot agent.""" - from ai_chat.agents import SyllabusAgent - - serializer = serializers.SyllabusChatRequestSerializer( - data=request.data, context={"request": request} - ) - serializer.is_valid(raise_exception=True) - if not request.session.session_key: - request.session.save() - cache_id = ( - request.user.email - if request.user.is_authenticated - else request.session.session_key - ) - # Make anonymous users share a common LiteLLM budget/rate limit. - user_id = request.user.email if request.user.is_authenticated else "anonymous" - message = serializer.validated_data.pop("message", "") - readable_id = (serializer.validated_data.pop("readable_id"),) - collection_name = (serializer.validated_data.pop("collection_name"),) - clear_history = serializer.validated_data.pop("clear_history", False) - agent = SyllabusAgent( - "Learning Resource Search AI Assistant", - user_id=user_id, - cache_key=f"{cache_id}_search_chat_history", - save_history=True, - collection_name=collection_name, - **serializer.validated_data, - ) - if clear_history: - agent.clear_chat_history() - return StreamingHttpResponse( - agent.get_completion(message, readable_id), - content_type="text/event-stream", - headers={"X-Accel-Buffering": "no"}, - ) diff --git a/ai_chat/views_test.py b/ai_chat/views_test.py deleted file mode 100644 index 7b3c297257..0000000000 --- a/ai_chat/views_test.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Unit tests for the views module.""" - -import pytest -from rest_framework.reverse import reverse - - -@pytest.mark.parametrize("clear_history", [True, False]) -@pytest.mark.parametrize("is_authenticated", [True, False]) -def test_post_search_agent_endpoint( - mocker, client, user, clear_history, is_authenticated -): - """Test SearchAgentView post endpoint""" - mocker.patch( - "ai_chat.permissions.SearchAgentPermissions.has_permission", return_value=True - ) - expected_answer = [b"Here ", b"are ", b"some ", b"results"] - expected_user_id = user.email if is_authenticated else "anonymous" - user_message = "Do you have any good physics courses?" - temperature = 0.1 - system_prompt = "Answer this question as best you can" - mock_agent = mocker.patch("ai_chat.agents.SearchAgent", autospec=True) - mock_agent.return_value.get_completion = mocker.Mock( - return_value=iter(expected_answer) - ) - model = "gpt-3.5-turbo" - if is_authenticated: - client.force_login(user) - resp = client.post( - f"{reverse('ai_chat:v0:chatbot_agent_api')}", - session=client.session, - data={ - "message": user_message, - "clear_history": clear_history, - "model": model, - "temperature": 0.1, - "instructions": system_prompt, - }, - ) - expected_cache_prefix = ( - user.email if is_authenticated else resp.request["session"].session_key - ) - mock_agent.assert_called_once_with( - "Learning Resource Search AI Assistant", - user_id=expected_user_id, - cache_key=f"{expected_cache_prefix}_search_chat_history", - save_history=True, - model=model, - instructions=system_prompt, - temperature=temperature, - ) - instantiated_agent = mock_agent.return_value - instantiated_agent.get_completion.assert_called_once_with(user_message) - assert instantiated_agent.clear_chat_history.call_count == ( - 1 if clear_history else 0 - ) - assert resp.status_code == 200 - assert list(resp.streaming_content) == expected_answer diff --git a/frontends/api/src/generated/v0/api.ts b/frontends/api/src/generated/v0/api.ts index e512d49eb3..17ca6cbd39 100644 --- a/frontends/api/src/generated/v0/api.ts +++ b/frontends/api/src/generated/v0/api.ts @@ -516,43 +516,6 @@ export interface ChannelUnitDetail { */ unit: LearningResourceOfferorDetail } -/** - * DRF serializer for chatbot requests - * @export - * @interface ChatRequestRequest - */ -export interface ChatRequestRequest { - /** - * - * @type {string} - * @memberof ChatRequestRequest - */ - message: string - /** - * - * @type {string} - * @memberof ChatRequestRequest - */ - model?: string - /** - * - * @type {number} - * @memberof ChatRequestRequest - */ - temperature?: number - /** - * - * @type {string} - * @memberof ChatRequestRequest - */ - instructions?: string - /** - * - * @type {boolean} - * @memberof ChatRequestRequest - */ - clear_history?: boolean -} /** * Serializer class for course run ContentFiles * @export @@ -5009,55 +4972,6 @@ export interface SubChannel { */ position?: number } -/** - * DRF serializer for syllabus chatbot requests - * @export - * @interface SyllabusChatRequestRequest - */ -export interface SyllabusChatRequestRequest { - /** - * - * @type {string} - * @memberof SyllabusChatRequestRequest - */ - message: string - /** - * - * @type {string} - * @memberof SyllabusChatRequestRequest - */ - model?: string - /** - * - * @type {number} - * @memberof SyllabusChatRequestRequest - */ - temperature?: number - /** - * - * @type {string} - * @memberof SyllabusChatRequestRequest - */ - instructions?: string - /** - * - * @type {boolean} - * @memberof SyllabusChatRequestRequest - */ - clear_history?: boolean - /** - * - * @type {string} - * @memberof SyllabusChatRequestRequest - */ - readable_id: string - /** - * - * @type {string} - * @memberof SyllabusChatRequestRequest - */ - collection_name?: string -} /** * * `0-to-5-hours` - <5 hours/week * `5-to-10-hours` - 5-10 hours/week * `10-to-20-hours` - 10-20 hours/week * `20-to-30-hours` - 20-30 hours/week * `30-plus-hours` - 30+ hours/week * @export @@ -7768,173 +7682,6 @@ export const ChannelsListChannelTypeEnum = { export type ChannelsListChannelTypeEnum = (typeof ChannelsListChannelTypeEnum)[keyof typeof ChannelsListChannelTypeEnum] -/** - * ChatAgentApi - axios parameter creator - * @export - */ -export const ChatAgentApiAxiosParamCreator = function ( - configuration?: Configuration, -) { - return { - /** - * Handle a POST request to the chatbot agent. - * @param {ChatRequestRequest} ChatRequestRequest - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - chatAgentCreate: async ( - ChatRequestRequest: ChatRequestRequest, - options: RawAxiosRequestConfig = {}, - ): Promise => { - // verify required parameter 'ChatRequestRequest' is not null or undefined - assertParamExists( - "chatAgentCreate", - "ChatRequestRequest", - ChatRequestRequest, - ) - const localVarPath = `/api/v0/chat_agent/` - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL) - let baseOptions - if (configuration) { - baseOptions = configuration.baseOptions - } - - const localVarRequestOptions = { - method: "POST", - ...baseOptions, - ...options, - } - const localVarHeaderParameter = {} as any - const localVarQueryParameter = {} as any - - localVarHeaderParameter["Content-Type"] = "application/json" - - setSearchParams(localVarUrlObj, localVarQueryParameter) - let headersFromBaseOptions = - baseOptions && baseOptions.headers ? baseOptions.headers : {} - localVarRequestOptions.headers = { - ...localVarHeaderParameter, - ...headersFromBaseOptions, - ...options.headers, - } - localVarRequestOptions.data = serializeDataIfNeeded( - ChatRequestRequest, - localVarRequestOptions, - configuration, - ) - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - } - }, - } -} - -/** - * ChatAgentApi - functional programming interface - * @export - */ -export const ChatAgentApiFp = function (configuration?: Configuration) { - const localVarAxiosParamCreator = ChatAgentApiAxiosParamCreator(configuration) - return { - /** - * Handle a POST request to the chatbot agent. - * @param {ChatRequestRequest} ChatRequestRequest - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async chatAgentCreate( - ChatRequestRequest: ChatRequestRequest, - options?: RawAxiosRequestConfig, - ): Promise< - (axios?: AxiosInstance, basePath?: string) => AxiosPromise - > { - const localVarAxiosArgs = await localVarAxiosParamCreator.chatAgentCreate( - ChatRequestRequest, - options, - ) - const index = configuration?.serverIndex ?? 0 - const operationBasePath = - operationServerMap["ChatAgentApi.chatAgentCreate"]?.[index]?.url - return (axios, basePath) => - createRequestFunction( - localVarAxiosArgs, - globalAxios, - BASE_PATH, - configuration, - )(axios, operationBasePath || basePath) - }, - } -} - -/** - * ChatAgentApi - factory interface - * @export - */ -export const ChatAgentApiFactory = function ( - configuration?: Configuration, - basePath?: string, - axios?: AxiosInstance, -) { - const localVarFp = ChatAgentApiFp(configuration) - return { - /** - * Handle a POST request to the chatbot agent. - * @param {ChatAgentApiChatAgentCreateRequest} requestParameters Request parameters. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - chatAgentCreate( - requestParameters: ChatAgentApiChatAgentCreateRequest, - options?: RawAxiosRequestConfig, - ): AxiosPromise { - return localVarFp - .chatAgentCreate(requestParameters.ChatRequestRequest, options) - .then((request) => request(axios, basePath)) - }, - } -} - -/** - * Request parameters for chatAgentCreate operation in ChatAgentApi. - * @export - * @interface ChatAgentApiChatAgentCreateRequest - */ -export interface ChatAgentApiChatAgentCreateRequest { - /** - * - * @type {ChatRequestRequest} - * @memberof ChatAgentApiChatAgentCreate - */ - readonly ChatRequestRequest: ChatRequestRequest -} - -/** - * ChatAgentApi - object-oriented interface - * @export - * @class ChatAgentApi - * @extends {BaseAPI} - */ -export class ChatAgentApi extends BaseAPI { - /** - * Handle a POST request to the chatbot agent. - * @param {ChatAgentApiChatAgentCreateRequest} requestParameters Request parameters. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof ChatAgentApi - */ - public chatAgentCreate( - requestParameters: ChatAgentApiChatAgentCreateRequest, - options?: RawAxiosRequestConfig, - ) { - return ChatAgentApiFp(this.configuration) - .chatAgentCreate(requestParameters.ChatRequestRequest, options) - .then((request) => request(this.axios, this.basePath)) - } -} - /** * CkeditorApi - axios parameter creator * @export @@ -9538,181 +9285,6 @@ export class ProgramCertificatesApi extends BaseAPI { } } -/** - * SyllabusAgentApi - axios parameter creator - * @export - */ -export const SyllabusAgentApiAxiosParamCreator = function ( - configuration?: Configuration, -) { - return { - /** - * Handle a POST request to the chatbot agent. - * @param {SyllabusChatRequestRequest} SyllabusChatRequestRequest - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - syllabusAgentCreate: async ( - SyllabusChatRequestRequest: SyllabusChatRequestRequest, - options: RawAxiosRequestConfig = {}, - ): Promise => { - // verify required parameter 'SyllabusChatRequestRequest' is not null or undefined - assertParamExists( - "syllabusAgentCreate", - "SyllabusChatRequestRequest", - SyllabusChatRequestRequest, - ) - const localVarPath = `/api/v0/syllabus_agent/` - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL) - let baseOptions - if (configuration) { - baseOptions = configuration.baseOptions - } - - const localVarRequestOptions = { - method: "POST", - ...baseOptions, - ...options, - } - const localVarHeaderParameter = {} as any - const localVarQueryParameter = {} as any - - localVarHeaderParameter["Content-Type"] = "application/json" - - setSearchParams(localVarUrlObj, localVarQueryParameter) - let headersFromBaseOptions = - baseOptions && baseOptions.headers ? baseOptions.headers : {} - localVarRequestOptions.headers = { - ...localVarHeaderParameter, - ...headersFromBaseOptions, - ...options.headers, - } - localVarRequestOptions.data = serializeDataIfNeeded( - SyllabusChatRequestRequest, - localVarRequestOptions, - configuration, - ) - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - } - }, - } -} - -/** - * SyllabusAgentApi - functional programming interface - * @export - */ -export const SyllabusAgentApiFp = function (configuration?: Configuration) { - const localVarAxiosParamCreator = - SyllabusAgentApiAxiosParamCreator(configuration) - return { - /** - * Handle a POST request to the chatbot agent. - * @param {SyllabusChatRequestRequest} SyllabusChatRequestRequest - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async syllabusAgentCreate( - SyllabusChatRequestRequest: SyllabusChatRequestRequest, - options?: RawAxiosRequestConfig, - ): Promise< - (axios?: AxiosInstance, basePath?: string) => AxiosPromise - > { - const localVarAxiosArgs = - await localVarAxiosParamCreator.syllabusAgentCreate( - SyllabusChatRequestRequest, - options, - ) - const index = configuration?.serverIndex ?? 0 - const operationBasePath = - operationServerMap["SyllabusAgentApi.syllabusAgentCreate"]?.[index]?.url - return (axios, basePath) => - createRequestFunction( - localVarAxiosArgs, - globalAxios, - BASE_PATH, - configuration, - )(axios, operationBasePath || basePath) - }, - } -} - -/** - * SyllabusAgentApi - factory interface - * @export - */ -export const SyllabusAgentApiFactory = function ( - configuration?: Configuration, - basePath?: string, - axios?: AxiosInstance, -) { - const localVarFp = SyllabusAgentApiFp(configuration) - return { - /** - * Handle a POST request to the chatbot agent. - * @param {SyllabusAgentApiSyllabusAgentCreateRequest} requestParameters Request parameters. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - syllabusAgentCreate( - requestParameters: SyllabusAgentApiSyllabusAgentCreateRequest, - options?: RawAxiosRequestConfig, - ): AxiosPromise { - return localVarFp - .syllabusAgentCreate( - requestParameters.SyllabusChatRequestRequest, - options, - ) - .then((request) => request(axios, basePath)) - }, - } -} - -/** - * Request parameters for syllabusAgentCreate operation in SyllabusAgentApi. - * @export - * @interface SyllabusAgentApiSyllabusAgentCreateRequest - */ -export interface SyllabusAgentApiSyllabusAgentCreateRequest { - /** - * - * @type {SyllabusChatRequestRequest} - * @memberof SyllabusAgentApiSyllabusAgentCreate - */ - readonly SyllabusChatRequestRequest: SyllabusChatRequestRequest -} - -/** - * SyllabusAgentApi - object-oriented interface - * @export - * @class SyllabusAgentApi - * @extends {BaseAPI} - */ -export class SyllabusAgentApi extends BaseAPI { - /** - * Handle a POST request to the chatbot agent. - * @param {SyllabusAgentApiSyllabusAgentCreateRequest} requestParameters Request parameters. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof SyllabusAgentApi - */ - public syllabusAgentCreate( - requestParameters: SyllabusAgentApiSyllabusAgentCreateRequest, - options?: RawAxiosRequestConfig, - ) { - return SyllabusAgentApiFp(this.configuration) - .syllabusAgentCreate( - requestParameters.SyllabusChatRequestRequest, - options, - ) - .then((request) => request(this.axios, this.basePath)) - } -} - /** * TestimonialsApi - axios parameter creator * @export diff --git a/main/settings.py b/main/settings.py index abde91598e..cdf5f36142 100644 --- a/main/settings.py +++ b/main/settings.py @@ -129,7 +129,6 @@ "testimonials", "data_fixtures", "vector_search", - "ai_chat", "mitol.scim.apps.ScimApp", ) @@ -781,29 +780,6 @@ def get_all_config_keys(): default=None, ) -# AI settings -AI_DEBUG = get_bool("AI_DEBUG", True) # noqa: FBT003 -AI_CACHE_TIMEOUT = get_int(name="AI_CACHE_TIMEOUT", default=3600) -AI_CACHE = get_string(name="AI_CACHE", default="redis") -AI_MIT_SEARCH_URL = get_string( - name="AI_MIT_SEARCH_URL", - default="https://api.learn.mit.edu/api/v1/learning_resources_search/", -) -AI_MIT_SYLLABUS_URL = get_string("AI_MIT_SYLLABUS_URL", "") -AI_MIT_SEARCH_LIMIT = get_int(name="AI_MIT_SEARCH_LIMIT", default=10) -AI_MODEL = get_string(name="AI_MODEL", default="gpt-4o") -AI_MODEL_API = get_string(name="AI_MODEL_API", default="openai") - -# AI proxy settings (aka LiteLLM) -AI_PROXY_CLASS = get_string(name="AI_PROXY_CLASS", default="") -AI_PROXY_URL = get_string(name="AI_PROXY_URL", default="") -AI_PROXY_AUTH_TOKEN = get_string(name="AI_PROXY_AUTH_TOKEN", default="") -AI_MAX_PARALLEL_REQUESTS = get_int(name="AI_MAX_PARALLEL_REQUESTS", default=10) -AI_TPM_LIMIT = get_int(name="AI_TPM_LIMIT", default=5000) -AI_RPM_LIMIT = get_int(name="AI_RPM_LIMIT", default=10) -AI_BUDGET_DURATION = get_string(name="AI_BUDGET_DURATION", default="60m") -AI_MAX_BUDGET = get_float(name="AI_MAX_BUDGET", default=0.05) -AI_ANON_LIMIT_MULTIPLIER = get_float(name="AI_ANON_LIMIT_MULTIPLIER", default=10.0) CONTENT_FILE_EMBEDDING_CHUNK_SIZE_OVERRIDE = get_int( name="CONTENT_FILE_EMBEDDING_CHUNK_SIZE", default=512 ) diff --git a/main/urls.py b/main/urls.py index e5af6279a3..a657f3e9bb 100644 --- a/main/urls.py +++ b/main/urls.py @@ -44,7 +44,6 @@ re_path(r"^o/", include("oauth2_provider.urls", namespace="oauth2_provider")), re_path(r"^admin/", admin.site.urls), re_path(r"", include("authentication.urls")), - re_path(r"", include("ai_chat.urls")), re_path(r"", include("channels.urls")), re_path(r"", include("profiles.urls")), re_path(r"", include("embedly.urls")), diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index 77ae8082d6..ffd70dc9bd 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -275,32 +275,6 @@ paths: schema: $ref: '#/components/schemas/Channel' description: '' - /api/v0/chat_agent/: - post: - operationId: chat_agent_create - description: Handle a POST request to the chatbot agent. - tags: - - chat_agent - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/ChatRequestRequest' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/ChatRequestRequest' - multipart/form-data: - schema: - $ref: '#/components/schemas/ChatRequestRequest' - required: true - responses: - '200': - content: - text/event-stream: - schema: - description: Chatbot response - type: string - description: '' /api/v0/ckeditor: get: operationId: ckeditor_retrieve @@ -576,32 +550,6 @@ paths: items: $ref: '#/components/schemas/ProgramCertificate' description: '' - /api/v0/syllabus_agent/: - post: - operationId: syllabus_agent_create - description: Handle a POST request to the chatbot agent. - tags: - - syllabus_agent - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/SyllabusChatRequestRequest' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/SyllabusChatRequestRequest' - multipart/form-data: - schema: - $ref: '#/components/schemas/SyllabusChatRequestRequest' - required: true - responses: - '200': - content: - text/event-stream: - schema: - description: Chatbot response - type: string - description: '' /api/v0/testimonials/: get: operationId: testimonials_list @@ -1749,30 +1697,6 @@ components: readOnly: true required: - unit - ChatRequestRequest: - type: object - description: DRF serializer for chatbot requests - properties: - message: - type: string - minLength: 1 - model: - type: string - minLength: 1 - default: gpt-4o - temperature: - type: number - format: double - maximum: 1.0 - minimum: 0.0 - instructions: - type: string - minLength: 1 - clear_history: - type: boolean - default: false - required: - - message ContentFile: type: object description: Serializer class for course run ContentFiles @@ -5051,37 +4975,6 @@ components: required: - channel - parent_channel - SyllabusChatRequestRequest: - type: object - description: DRF serializer for syllabus chatbot requests - properties: - message: - type: string - minLength: 1 - model: - type: string - minLength: 1 - default: gpt-4o - temperature: - type: number - format: double - maximum: 1.0 - minimum: 0.0 - instructions: - type: string - minLength: 1 - clear_history: - type: boolean - default: false - readable_id: - type: string - minLength: 1 - collection_name: - type: string - minLength: 1 - required: - - message - - readable_id TimeCommitmentEnum: enum: - 0-to-5-hours