diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..50bb780 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,71 @@ +# Contributing + +## Local Dev Setup + +### Clone the repo + +`git clone git@github.com:vintasoftware/django-ai-assistant.git` + +### Set up a virtualenv, optionally set up nvm, and activate your environment(s) + +You can use [pyenv](https://github.com/pyenv/pyenv), [pipenv](https://github.com/pypa/pipenv/blob/main/docs/installation.md), vanilla venvs or the tool of your choice. + +For installing Node, we recommend [NVM](https://github.com/nvm-sh/nvm). + +### Install dependencies + +#### Backend + +`poetry install` + +#### Frontend + +```bash +cd frontend +npm install +``` + +### Install pre-commit hooks + +`pre-commit install` + +It's critical to run the pre-commit hooks before pushing your code to follow the project's code style, and avoid linting errors. + +### Developing with the example project + +Run the frontend project in build:watch mode: + +```bash +cd frontend +npm run build:watch +``` + +Then follow the instructions in the [example README](https://github.com/vintasoftware/django-ai-assistant/tree/main/example#readme). + +## Tests + +Run tests with: + +```bash +poetry run pytest +``` + +The tests use `pytest-vcr` to record and replay HTTP requests to AI models. + +If you're implementing a new test that needs to call a real AI model, you need to set the `OPENAI_API_KEY` environment variable on root `.env` file. +Then, you will run the tests in record mode: + +```bash +poetry run pytest --record-mode=once +``` + +## Documentation + +We use [mkdocs-material](https://squidfunk.github.io/mkdocs-material/) to generate the documentation from markdown files. +Check the files in the `docs` directory. + +To run the documentation locally, you need to run: + +```bash +poetry run mkdocs serve +``` diff --git a/README.md b/README.md index 048a666..6539f63 100644 --- a/README.md +++ b/README.md @@ -1,97 +1,25 @@ [![Django CI](https://github.com/vintasoftware/django-ai-assistant/actions/workflows/django.yml/badge.svg)](https://github.com/vintasoftware/django-ai-assistant/actions/workflows/django.yml) [![Node CI](https://github.com/vintasoftware/django-ai-assistant/actions/workflows/node.yml/badge.svg)](https://github.com/vintasoftware/django-ai-assistant/actions/workflows/node.yml) -# django-ai-assistant +# django-ai-assistant ![docs/images/robot-happy-outline.svg] -Django app to integrate with [OpenAI Assistants API](https://platform.openai.com/docs/assistants/overview). Supports [Function calling](https://platform.openai.com/docs/assistants/tools/function-calling). +Combine the power of LLMs with Django's productivity to build intelligent applications. +Let AI Assistants call methods from Django's side and do anything your users need! -⚠️ Under heavy development. Not launched yet! +Use AI Tool Calling and RAG with Django to easily build state of the art AI Assistants. -## Dev Setup - -### Clone the repo - -`git clone git@github.com:vintasoftware/django-ai-assistant.git` - -### Set up a virtualenv, optionally set up nvm, and activate your environment(s) - -You can use [pyenv](https://github.com/pyenv/pyenv), [pipenv](https://github.com/pypa/pipenv/blob/main/docs/installation.md), vanilla venvs or the tool of your choice. - -[NVM](https://github.com/nvm-sh/nvm) - -### Install vite - -`npm install -g vite` or install locally if you prefer - -### Create a .env file at the root of the project - -`cp .env.example .env` - -To gather the API keys for these tools, head to the following links: -- [OpenAI](https://platform.openai.com/api-keys) -- [Weather](https://www.weatherapi.com/) -- [Tavily](https://app.tavily.com/home) -- [Firecrawl](https://www.firecrawl.dev/) - -### Build the frontend - -`cd frontend` - -`vite build` - -### Create a .env file at the example folder - -`cd example` - -`cp .env.example .env` - -### Install dependencies - -#### Backend - -`cd example` - -`poetry install` - -Install pre-commit hooks: - -`pre-commit install` - -#### Frontend - -`cd example` - -`npm install` - -### Run the project - -From the example folder: - -#### Frontend - -`npm run start` - -#### Backend - -`python manage.py migrate` - -`python manage.py createsuperuser` - -`python manage.py runserver` - -### Log in to Django Admin - -Go to /admin and log in with your superuser account. +⚠️ Under heavy development. Not publicly launched yet! +Please check the documentation: [https://vintasoftware.github.io/django-ai-assistant/](https://vintasoftware.github.io/django-ai-assistant/) ## Contributing -If you wish to contribute to this project, please first discuss the change you wish to make via an [issue](https://github.com/vintasoftware/django-react-boilerplate/issues). +You're welcome to contribute with Django AI Assistant! Please feel free to tackle existing [issues](https://github.com/vintasoftware/django-ai-assistant/issues). If you have a new idea, please open a new issue to discuss it. -Check our [contributing guide](https://github.com/vintasoftware/django-react-boilerplate/blob/main/CONTRIBUTING.md) to learn more about our development process and how you can test your changes to the boilerplate. +Check our [contributing guide](CONTRIBUTING.md) to learn more about how to develop and test the project locally, before opening a pull request. ## Commercial Support -[![alt text](https://avatars2.githubusercontent.com/u/5529080?s=80&v=4 "Vinta Logo")](https://www.vinta.com.br/) +[![alt text](https://avatars2.githubusercontent.com/u/5529080?s=80&v=4 "Vinta Logo")](https://www.vintasoftware.com/) -This project is maintained by [Vinta Software](https://www.vinta.com.br/) and is used in products of Vinta's clients. We are always looking for exciting work! If you need any commercial support, feel free to get in touch: contact@vinta.com.br +This is an open-source project maintained by [Vinta Software](https://www.vinta.com.br/) and is used in products of Vinta's clients. We are always looking for exciting work! If you need any commercial support, feel free to get in touch: contact@vinta.com.br diff --git a/django_ai_assistant/api/views.py b/django_ai_assistant/api/views.py index a43f0be..a695823 100644 --- a/django_ai_assistant/api/views.py +++ b/django_ai_assistant/api/views.py @@ -16,7 +16,8 @@ ThreadSchema, ThreadSchemaIn, ) -from django_ai_assistant.exceptions import AIUserNotAllowedError +from django_ai_assistant.conf import app_settings +from django_ai_assistant.exceptions import AIAssistantNotDefinedError, AIUserNotAllowedError from django_ai_assistant.helpers import use_cases from django_ai_assistant.models import Message, Thread @@ -28,14 +29,18 @@ def get_openapi_operation_id(self, operation: Operation) -> str: return (package_name + "_" + name).replace(".", "_") -api = API( - title=package_name, - version=version, - urls_namespace="django_ai_assistant", - # Add auth to all endpoints - auth=django_auth, - csrf=True, -) +def init_api(): + return API( + title=package_name, + version=version, + urls_namespace="django_ai_assistant", + # Add auth to all endpoints + auth=django_auth, + csrf=True, + ) + + +api = app_settings.call_fn("INIT_API_FN") @api.exception_handler(AIUserNotAllowedError) @@ -47,6 +52,15 @@ def ai_user_not_allowed_handler(request, exc): ) +@api.exception_handler(AIAssistantNotDefinedError) +def ai_assistant_not_defined_handler(request, exc): + return api.create_response( + request, + {"message": str(exc)}, + status=404, + ) + + @api.get("assistants/", response=List[AssistantSchema], url_name="assistants_list") def list_assistants(request): return list(use_cases.get_assistants_info(user=request.user, request=request)) @@ -61,7 +75,7 @@ def get_assistant(request, assistant_id: str): @api.get("threads/", response=List[ThreadSchema], url_name="threads_list_create") def list_threads(request): - return list(use_cases.get_threads(user=request.user, request=request)) + return list(use_cases.get_threads(user=request.user)) @api.post("threads/", response=ThreadSchema, url_name="threads_list_create") @@ -77,7 +91,7 @@ def get_thread(request, thread_id: Any): thread_id=thread_id, user=request.user, request=request ) except Thread.DoesNotExist: - raise Http404("No %s matches the given query." % Thread._meta.object_name) from None + raise Http404(f"No Thread with id={thread_id} found") from None return thread @@ -100,10 +114,9 @@ def delete_thread(request, thread_id: Any): response=List[ThreadMessagesSchemaOut], url_name="messages_list_create", ) -def list_thread_messages(request, thread_id: Any): - messages = use_cases.get_thread_messages( - thread_id=thread_id, user=request.user, request=request - ) +def list_thread_messages(request, thread_id: str): + thread = get_object_or_404(Thread, id=thread_id) + messages = use_cases.get_thread_messages(thread=thread, user=request.user, request=request) return [message_to_dict(m)["data"] for m in messages] diff --git a/django_ai_assistant/conf.py b/django_ai_assistant/conf.py index fa9f021..47d8846 100644 --- a/django_ai_assistant/conf.py +++ b/django_ai_assistant/conf.py @@ -9,6 +9,7 @@ DEFAULTS = { + "INIT_API_FN": "django_ai_assistant.api.views.init_api", "CAN_CREATE_THREAD_FN": "django_ai_assistant.permissions.allow_all", "CAN_VIEW_THREAD_FN": "django_ai_assistant.permissions.owns_thread", "CAN_UPDATE_THREAD_FN": "django_ai_assistant.permissions.owns_thread", diff --git a/django_ai_assistant/exceptions.py b/django_ai_assistant/exceptions.py index 1375b06..ceb75ec 100644 --- a/django_ai_assistant/exceptions.py +++ b/django_ai_assistant/exceptions.py @@ -1,10 +1,16 @@ class AIAssistantMisconfiguredError(Exception): + """Raised when the AI assistant is misconfigured, e.g. when assistant id is invalid.""" + pass class AIAssistantNotDefinedError(Exception): + """Raised when the AI assistant is not defined when trying to get it by id.""" + pass class AIUserNotAllowedError(Exception): + """Raised when the user has no permission to manage a Thread, Message, or AIAssistant.""" + pass diff --git a/django_ai_assistant/helpers/assistants.py b/django_ai_assistant/helpers/assistants.py index 1912f4e..0ddf67e 100644 --- a/django_ai_assistant/helpers/assistants.py +++ b/django_ai_assistant/helpers/assistants.py @@ -44,10 +44,11 @@ class AIAssistant(abc.ABC): # noqa: F821 """Base class for AI Assistants. Subclasses must define at least the following attributes: - - id: str - - name: str - - instructions: str - - model: str + + * id: str + * name: str + * instructions: str + * model: str Subclasses can override the public methods to customize the behavior of the assistant.\n Tools can be added to the assistant by decorating methods with `@method_tool`.\n @@ -102,7 +103,21 @@ class AIAssistant(abc.ABC): # noqa: F821 Automatically populated by when a subclass is declared.\n Use `get_cls_registry` and `get_cls` to access the registry.""" - def __init__(self, *, user=None, request=None, view=None, **kwargs): + def __init__(self, *, user=None, request=None, view=None, **kwargs: Any): + """Initialize the AIAssistant instance.\n + Optionally set the current user, request, and view for the assistant.\n + Those can be used in any `@method_tool` to customize behavior.\n + + Args: + user (Any | None): The current user the assistant is helping. A model instance. + Defaults to `None`. Stored in `self._user`. + request (Any | None): The current Django request the assistant was initialized with. + A request instance. Defaults to `None`. Stored in `self._request`. + view (Any | None): The current Django view the assistant was initialized with. + A view instance. Defaults to `None`. Stored in `self._view`. + **kwargs: Extra keyword arguments passed to the constructor. Stored in `self._init_kwargs`. + """ + self._user = user self._request = request self._view = view @@ -110,7 +125,7 @@ def __init__(self, *, user=None, request=None, view=None, **kwargs): self._set_method_tools() - def __init_subclass__(cls, **kwargs): + def __init_subclass__(cls, **kwargs: Any): """Called when a class is subclassed from AIAssistant. This method is automatically invoked when a new subclass of AIAssistant @@ -499,7 +514,7 @@ def as_chain(self, thread_id: Any | None) -> Runnable[dict, dict]: return agent_with_chat_history - def invoke(self, *args, thread_id: Any | None, **kwargs): + def invoke(self, *args: Any, thread_id: Any | None, **kwargs: Any) -> dict: """Invoke the assistant Langchain chain with the given arguments and keyword arguments.\n This is the lower-level method to run the assistant.\n The chain is created by the `as_chain` method.\n @@ -518,7 +533,7 @@ def invoke(self, *args, thread_id: Any | None, **kwargs): chain = self.as_chain(thread_id) return chain.invoke(*args, **kwargs) - def run(self, message, thread_id: Any | None, **kwargs): + def run(self, message: str, thread_id: Any | None, **kwargs: Any) -> str: """Run the assistant with the given message and thread ID.\n This is the higher-level method to run the assistant.\n @@ -539,10 +554,10 @@ def run(self, message, thread_id: Any | None, **kwargs): **kwargs, )["output"] - def _run_as_tool(self, message: str, **kwargs): + def _run_as_tool(self, message: str, **kwargs: Any) -> str: return self.run(message, thread_id=None, **kwargs) - def as_tool(self, description) -> BaseTool: + def as_tool(self, description: str) -> BaseTool: """Create a tool from the assistant.\n This is useful to compose assistants.\n diff --git a/django_ai_assistant/helpers/use_cases.py b/django_ai_assistant/helpers/use_cases.py index da0fa13..a528da2 100644 --- a/django_ai_assistant/helpers/use_cases.py +++ b/django_ai_assistant/helpers/use_cases.py @@ -2,7 +2,7 @@ from django.http import HttpRequest -from langchain_core.messages import BaseMessage, HumanMessage +from langchain_core.messages import BaseMessage from django_ai_assistant.exceptions import ( AIAssistantNotDefinedError, @@ -17,6 +17,7 @@ can_delete_message, can_delete_thread, can_run_assistant, + can_update_thread, can_view_thread, ) @@ -25,7 +26,20 @@ def get_assistant_cls( assistant_id: str, user: Any, request: HttpRequest | None = None, -): +) -> type[AIAssistant]: + """Get assistant class by id.\n + Uses `AI_ASSISTANT_CAN_RUN_ASSISTANT_FN` permission to check if user can run the assistant. + + Args: + assistant_id (str): Assistant id to get + user (Any): Current user + request (HttpRequest | None): Current request, if any + Returns: + type[AIAssistant]: Assistant class with the given id + Raises: + AIAssistantNotDefinedError: If assistant with the given id is not found + AIUserNotAllowedError: If user is not allowed to use the assistant + """ if assistant_id not in AIAssistant.get_cls_registry(): raise AIAssistantNotDefinedError(f"Assistant with id={assistant_id} not found") assistant_cls = AIAssistant.get_cls(assistant_id) @@ -42,7 +56,20 @@ def get_single_assistant_info( assistant_id: str, user: Any, request: HttpRequest | None = None, -): +) -> dict[str, str]: + """Get assistant info id. Returns a dictionary with the assistant id and name.\n + Uses `AI_ASSISTANT_CAN_RUN_ASSISTANT_FN` permission to check if user can see the assistant. + + Args: + assistant_id (str): Assistant id to get + user (Any): Current user + request (HttpRequest | None): Current request, if any + Returns: + dict[str, str]: dict like `{"id": "personal_ai", "name": "Personal AI"}` + Raises: + AIAssistantNotDefinedError: If assistant with the given id is not found + AIUserNotAllowedError: If user is not allowed to see the assistant + """ assistant_cls = get_assistant_cls(assistant_id, user, request) return { @@ -54,11 +81,25 @@ def get_single_assistant_info( def get_assistants_info( user: Any, request: HttpRequest | None = None, -): - return [ - get_assistant_cls(assistant_id=assistant_id, user=user, request=request) - for assistant_id in AIAssistant.get_cls_registry().keys() - ] +) -> list[dict[str, str]]: + """Get all assistants info. Returns a list of dictionaries with the assistant id and name.\n + Uses `AI_ASSISTANT_CAN_RUN_ASSISTANT_FN` permission to check the assistants the user can see, + and returns only the ones the user can see. + + Args: + user (Any): Current user + request (HttpRequest | None): Current request, if any + Returns: + list[dict[str, str]]: List of dicts like `[{"id": "personal_ai", "name": "Personal AI"}, ...]` + """ + assistant_info_list = [] + for assistant_id in AIAssistant.get_cls_registry().keys(): + try: + info = get_single_assistant_info(assistant_id, user, request) + assistant_info_list.append(info) + except AIUserNotAllowedError: + continue + return assistant_info_list def create_message( @@ -67,7 +108,23 @@ def create_message( user: Any, content: Any, request: HttpRequest | None = None, -): +) -> dict: + """Create a message in a thread, and right after runs the assistant to get the AI response.\n + Uses `AI_ASSISTANT_CAN_RUN_ASSISTANT_FN` permission to check if user can run the assistant.\n + Uses `AI_ASSISTANT_CAN_CREATE_MESSAGE_FN` permission to check if user can create a message in the thread. + + Args: + assistant_id (str): Assistant id to use to get the AI response + thread (Thread): Thread where to create the message + user (Any): Current user + content (Any): Message content, usually a string + request (HttpRequest | None): Current request, if any + Returns: + dict: The output of the assistant chain, + structured like `{"output": "assistant response", "history": ...}` + Raises: + AIUserNotAllowedError: If user is not allowed to create messages in the thread + """ assistant_cls = get_assistant_cls(assistant_id, user, request) if not can_create_message(thread=thread, user=user, request=request): @@ -86,7 +143,19 @@ def create_thread( name: str, user: Any, request: HttpRequest | None = None, -): +) -> Thread: + """Create a thread.\n + Uses `AI_ASSISTANT_CAN_CREATE_THREAD_FN` permission to check if user can create a thread. + + Args: + name (str): Thread name + user (Any): Current user + request (HttpRequest | None): Current request, if any + Returns: + Thread: Created thread model instance + Raises: + AIUserNotAllowedError: If user is not allowed to create threads + """ if not can_create_thread(user=user, request=request): raise AIUserNotAllowedError("User is not allowed to create threads") @@ -98,7 +167,19 @@ def get_single_thread( thread_id: Any, user: Any, request: HttpRequest | None = None, -): +) -> Thread: + """Get a single thread by id.\n + Uses `AI_ASSISTANT_CAN_VIEW_THREAD_FN` permission to check if user can view the thread. + + Args: + thread_id (str): Thread id to get + user (Any): Current user + request (HttpRequest | None): Current request, if any + Returns: + Thread: Thread model instance + Raises: + AIUserNotAllowedError: If user is not allowed to view the thread + """ thread = Thread.objects.get(id=thread_id) if not can_view_thread(thread=thread, user=user, request=request): @@ -107,10 +188,14 @@ def get_single_thread( return thread -def get_threads( - user: Any, - request: HttpRequest | None = None, -): +def get_threads(user: Any) -> list[Thread]: + """Get all user owned threads.\n + + Args: + user (Any): Current user + Returns: + list[Thread]: List of thread model instances + """ return list(Thread.objects.filter(created_by=user)) @@ -119,8 +204,21 @@ def update_thread( name: str, user: Any, request: HttpRequest | None = None, -): - if not can_delete_thread(thread=thread, user=user, request=request): +) -> Thread: + """Update thread name.\n + Uses `AI_ASSISTANT_CAN_UPDATE_THREAD_FN` permission to check if user can update the thread. + + Args: + thread (Thread): Thread model instance to update + name (str): New thread name + user (Any): Current user + request (HttpRequest | None): Current request, if any + Returns: + Thread: Updated thread model instance + Raises: + AIUserNotAllowedError: If user is not allowed to update the thread + """ + if not can_update_thread(thread=thread, user=user, request=request): raise AIUserNotAllowedError("User is not allowed to update this thread") thread.name = name @@ -132,45 +230,60 @@ def delete_thread( thread: Thread, user: Any, request: HttpRequest | None = None, -): +) -> None: + """Delete a thread.\n + Uses `AI_ASSISTANT_CAN_DELETE_THREAD_FN` permission to check if user can delete the thread. + + Args: + thread (Thread): Thread model instance to delete + user (Any): Current user + request (HttpRequest | None): Current request, if any + Raises: + AIUserNotAllowedError: If user is not allowed to delete the thread + """ if not can_delete_thread(thread=thread, user=user, request=request): raise AIUserNotAllowedError("User is not allowed to delete this thread") - return thread.delete() + thread.delete() def get_thread_messages( - thread_id: Any, + thread: Thread, user: Any, request: HttpRequest | None = None, ) -> list[BaseMessage]: + """Get all messages in a thread.\n + Uses `AI_ASSISTANT_CAN_VIEW_THREAD_FN` permission to check if user can view the thread. + + Args: + thread (Thread): Thread model instance to get messages from + user (Any): Current user + request (HttpRequest | None): Current request, if any + Returns: + list[BaseMessage]: List of message instances + """ # TODO: have more permissions for threads? View thread permission? - thread = Thread.objects.get(id=thread_id) if user != thread.created_by: raise AIUserNotAllowedError("User is not allowed to view messages in this thread") return DjangoChatMessageHistory(thread.id).get_messages() -def create_thread_message_as_user( - thread_id: Any, - content: str, - user: Any, - request: HttpRequest | None = None, -): - # TODO: have more permissions for threads? View thread permission? - thread = Thread.objects.get(id=thread_id) - if user != thread.created_by: - raise AIUserNotAllowedError("User is not allowed to create messages in this thread") - - DjangoChatMessageHistory(thread.id).add_messages([HumanMessage(content=content)]) - - def delete_message( message: Message, user: Any, request: HttpRequest | None = None, ): + """Delete a message.\n + Uses `AI_ASSISTANT_CAN_DELETE_MESSAGE_FN` permission to check if user can delete the message. + + Args: + message (Message): Message model instance to delete + user (Any): Current user + request (HttpRequest | None): Current request, if any + Raises: + AIUserNotAllowedError: If user is not allowed to delete the message + """ if not can_delete_message(message=message, user=user, request=request): raise AIUserNotAllowedError("User is not allowed to delete this message") diff --git a/django_ai_assistant/models.py b/django_ai_assistant/models.py index e5415e5..510cbcd 100644 --- a/django_ai_assistant/models.py +++ b/django_ai_assistant/models.py @@ -7,16 +7,25 @@ class Thread(models.Model): + """Thread model. A thread is a collection of messages between a user and the AI assistant. + Also called conversation or session.""" + messages: Manager["Message"] name = models.CharField(max_length=255, blank=True) + """Name of the thread. Can be blank.""" created_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, related_name="ai_assistant_threads", null=True, ) + """User who created the thread. Can be null. Set to null/None when user is deleted.""" created_at = models.DateTimeField(auto_now_add=True) + """Date and time when the thread was created. + Automatically set when the thread is created.""" updated_at = models.DateTimeField(auto_now=True) + """Date and time when the thread was last updated. + Automatically set when the thread is updated.""" class Meta: verbose_name = "Thread" @@ -24,18 +33,29 @@ class Meta: ordering = ("-created_at",) indexes = (Index(F("created_at").desc(), name="thread_created_at_desc"),) - def __str__(self): + def __str__(self) -> str: + """Return the name of the thread as the string representation of the thread.""" return self.name def __repr__(self) -> str: + """Return the string representation of the thread like ''""" return f"" class Message(models.Model): + """Message model. A message is a text that is part of a thread. + A message can be sent by a user or the AI assistant.\n + The message data is stored as a JSON field called `message`.""" + thread = models.ForeignKey(Thread, on_delete=models.CASCADE, related_name="messages") + """Thread to which the message belongs.""" thread_id: Any # noqa: A003 - message = models.JSONField() # langchain BaseMessage + message = models.JSONField() + """Message content. This is a serialized Langchain `BaseMessage` that was serialized + with `message_to_dict` and can be deserialized with `messages_from_dict`.""" created_at = models.DateTimeField(auto_now_add=True) + """Date and time when the message was created. + Automatically set when the message is created.""" # TODO: add created_by field class Meta: @@ -44,8 +64,11 @@ class Meta: ordering = ("created_at",) indexes = (Index(F("created_at"), name="message_created_at"),) - def __str__(self): + def __str__(self) -> str: + """Return internal message data from `message` attribute + as the string representation of the message.""" return json.dumps(self.message) def __repr__(self) -> str: + """Return the string representation of the message like ''""" return f"" diff --git a/docs/assistants-ref.md b/docs/assistants-ref.md new file mode 100644 index 0000000..3de3689 --- /dev/null +++ b/docs/assistants-ref.md @@ -0,0 +1,7 @@ +# django_ai_assistant.helpers.assistants + +::: django_ai_assistant.helpers.assistants + options: + show_bases: false + filters: + - "!__init_subclass__" diff --git a/docs/contributing.md b/docs/contributing.md new file mode 120000 index 0000000..44fcc63 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1 @@ +../CONTRIBUTING.md \ No newline at end of file diff --git a/docs/images/robot-happy-outline.svg b/docs/images/robot-happy-outline.svg new file mode 100644 index 0000000..83c6110 --- /dev/null +++ b/docs/images/robot-happy-outline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 8e3464f..f1bc789 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,24 +1,21 @@ -# Django AI Assistant +# Django AI Assistant :material-robot-happy-outline: -Implement powerful AI Assistants using Django. -Combine the power of Large Language Models with Django's productivity. +Combine the power of Large Language Models with Django's productivity to build intelligent applications. -Regardless of the feasibility of AGI, AI assistants are (already!) a new paradigm for computation. -AI agents and assistants allow devs to easily build applications with smart decision logic -that would otherwise be too expensive to build and maintain. +Regardless of the feasibility of AGI, AI assistants are a new paradigm for computation. +AI agents and assistants allow devs to easily build applications that make smart decisions. The latest LLMs from major AI providers have a "killer feature" called Tool Calling, -which enables AI models to call provided methods from Django's side, and essentially -do anything a Django view can, such as accessing DBs, checking permissions, sending emails, -downloading and uploading media files, etc. +which enables AI models to call methods from Django's side, and essentially +do anything a Django view can, such as DB queries, file management, external API calls, etc. While users commonly interact with LLMs via conversations, AI Assistants can do a lot with any kind of string input, including JSON. -Your application's end users won't even realize that a LLM is doing the heavy-lifting behind the scenes! -Some ideas for innovative AI assistants: +Your end users won't even realize that a LLM is doing the heavy-lifting behind the scenes! +Some ideas for innovative AI assistants include: - A movie recommender chatbot that helps users manage their movie backlogs -- An autofill button for certain forms of your application -- Personalized email reminders that consider users' written preferences and the application's recent notifications -- A real-time audio guide for tourists that recommends attractions given the user's current location +- An autofill button for forms of your application +- Tailored email reminders that consider users' activity +- A real-time tourist guide that recommends attractions given the user's current location -We have an open-source example with some of those applications, but it's best to start with the [Get Started](get-started.md) guide. +We provide examples for some of those applications. [Get Started](get-started.md) now! diff --git a/docs/models-ref.md b/docs/models-ref.md new file mode 100644 index 0000000..c74e7c1 --- /dev/null +++ b/docs/models-ref.md @@ -0,0 +1,5 @@ +# django_ai_assistant.models + +::: django_ai_assistant.models + options: + show_bases: true diff --git a/docs/tutorial.md b/docs/tutorial.md index e1ab309..6124482 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -35,7 +35,6 @@ To create an AI Assistant, you need to: ```python title="myapp/ai_assistants.py" from django_ai_assistant import AIAssistant - class WeatherAIAssistant(AIAssistant): id = "weather_assistant" name = "Weather Assistant" @@ -50,12 +49,11 @@ such as getting the current date and finding the current weather by calling an A Use the `@method_tool` decorator to define a tool method in the AI Assistant: -```{.python title="myapp/ai_assistants.py" hl_lines="15-22"} +```{.python title="myapp/ai_assistants.py" hl_lines="14-21"} from django.utils import timezone from django_ai_assistant import AIAssistant, method_tool import json - class WeatherAIAssistant(AIAssistant): id = "weather_assistant" name = "Weather Assistant" @@ -94,10 +92,9 @@ AI: The weather in NYC is sunny with a temperature of 25°C. You have access to the current request user in tools: -```{.python title="myapp/ai_assistants.py" hl_lines=13} +```{.python title="myapp/ai_assistants.py" hl_lines=12} from django_ai_assistant import AIAssistant, method_tool - class PersonalAIAssistant(AIAssistant): id = "personal_assistant" name = "Personal Assistant" @@ -112,11 +109,10 @@ class PersonalAIAssistant(AIAssistant): You can also add any Django logic to tools, such as querying the database: -```{.python title="myapp/ai_assistants.py" hl_lines=14-16} +```{.python title="myapp/ai_assistants.py" hl_lines=13-15} from django_ai_assistant import AIAssistant, method_tool import json - class IssueManagementAIAssistant(AIAssistant): id = "issue_mgmt_assistant" name = "Issue Management Assistant" @@ -153,11 +149,10 @@ Then, set the `TAVILY_API_KEY` environment variable. You'll need to sign up at [ Finally, add the tool to your AI Assistant class by overriding the `get_tools` method: -```{.python title="myapp/ai_assistants.py" hl_lines="2 20"} +```{.python title="myapp/ai_assistants.py" hl_lines="2 19"} from django_ai_assistant import AIAssistant from langchain_community.tools.tavily_search import TavilySearchResults - class MovieSearchAIAssistant(AIAssistant): id = "movie_search_assistant" # noqa: A003 instructions = ( @@ -191,7 +186,6 @@ You can manually call an AI Assistant from anywhere in your Django application: ```python from myapp.ai_assistants import WeatherAIAssistant - assistant = WeatherAIAssistant() output = assistant.run("What's the weather in New York City?") assert output == "The weather in NYC is sunny with a temperature of 25°C." @@ -210,20 +204,18 @@ and automatically retrieved then passed to the LLM when calling the AI Assistant To create a `Thread`, you can use a helper from the `django_ai_assistant.use_cases` module. For example: -```{.python hl_lines="5 9"} +```{.python hl_lines="4 8"} from django_ai_assistant.use_cases import create_thread, get_thread_messages from myapp.ai_assistants import WeatherAIAssistant - -thread = create_thread(name="Weather Chat", user=some_user) +thread = create_thread(name="Weather Chat", user=user) assistant = WeatherAIAssistant() assistant.run("What's the weather in New York City?", thread_id=thread.id) -messages = get_thread_messages(thread) # returns both user and AI messages +messages = get_thread_messages(thread=thread, user=user) # returns both user and AI messages ``` -More CRUD helpers are available at `django_ai_assistant.use_cases` module. Check the API Reference for more information. - +More CRUD helpers are available at `django_ai_assistant.use_cases` module. Check the [Reference](use-cases-ref.md) for more information. ### Using built-in API views @@ -233,7 +225,6 @@ such as a React application or a mobile app. Add the following to your Django pr ```python title="myproject/urls.py" from django.urls import include, path - urlpatterns = [ path("ai-assistant/", include("django_ai_assistant.urls")), ... @@ -243,6 +234,28 @@ urlpatterns = [ The built-in API supports retrieval of Assistants info, as well as CRUD for Threads and Messages. It has a OpenAPI schema that you can explore at `ai-assistant/docs/`. + +#### Configuring the API + +The built-in API is implemented using [Django Ninja](https://django-ninja.dev/reference/api/). By default, it is initialized with the following setting: + +```python title="myproject/settings.py" +AI_ASSISTANT_INIT_API_FN = "django_ai_assistant.api.views.init_api" +``` + +You can override this setting in your Django project's `settings.py` to customize the API, such as using a different authentication method or modifying other configurations. + +The method signature for `AI_ASSISTANT_INIT_API_FN` is as follows: + +```python +from ninja import NinjaAPI + +def init_api(): + return NinjaAPI(...) +``` + +By providing your own implementation of `init_api`, you can tailor the API setup to better fit your project's requirements. + ### Configuring permissions The API uses the helpers from the `django_ai_assistant.use_cases` module, which have permission checks @@ -270,7 +283,6 @@ Thread permission signatures look like this: from django_ai_assistant.models import Thread from django.http import HttpRequest - def check_custom_thread_permission( thread: Thread, user: Any, @@ -284,7 +296,6 @@ While Message permission signatures look like this: from django_ai_assistant.models import Thread, Message from django.http import HttpRequest - def check_custom_message_permission( message: Message, thread: Thread, @@ -304,7 +315,6 @@ but you can use [any chat model from Langchain that supports Tool Calling](https from django_ai_assistant import AIAssistant from langchain_anthropic import ChatAnthropic - class WeatherAIAssistant(AIAssistant): id = "weather_assistant" name = "Weather Assistant" @@ -329,15 +339,13 @@ class WeatherAIAssistant(AIAssistant): One AI Assistant can call another AI Assistant as a tool. This is useful for composing complex AI Assistants. Use the `as_tool` method for that: -```{.python title="myapp/ai_assistants.py" hl_lines="14 16"} +```{.python title="myapp/ai_assistants.py" hl_lines="12 14"} class SimpleAssistant(AIAssistant): ... - class AnotherSimpleAssistant(AIAssistant): ... - class ComplexAssistant(AIAssistant): ... @@ -369,10 +377,9 @@ For this to work, your must do the following in your AI Assistant: For example: -```{.python title="myapp/ai_assistants.py" hl_lines="12 16 18"} +```{.python title="myapp/ai_assistants.py" hl_lines="11 15 17"} from django_ai_assistant import AIAssistant - class DocsAssistant(AIAssistant): id = "docs_assistant" # noqa: A003 name = "Docs Assistant" @@ -396,5 +403,4 @@ shows an example of a RAG-powered AI Assistant that's able to answer questions a ### Further configuration of AI Assistants -You can further configure the `AIAssistant` subclasses by overriding its public methods. Check the API Reference for more information. - +You can further configure the `AIAssistant` subclasses by overriding its public methods. Check the [Reference](assistants-ref.md) for more information. diff --git a/docs/use-cases-ref.md b/docs/use-cases-ref.md new file mode 100644 index 0000000..51f9150 --- /dev/null +++ b/docs/use-cases-ref.md @@ -0,0 +1,3 @@ +# django_ai_assistant.helpers.use_cases + +::: django_ai_assistant.helpers.use_cases diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..2112f70 --- /dev/null +++ b/example/README.md @@ -0,0 +1,88 @@ +# Example + +Django AI Assistant examples. This is a Django project that integrates with Django AI Assistant library. + +Most examples are inside a React frontend, but there is also a HTMX example. + +## Installation + +Go to project root, then frontend dir and build the frontend library: + +```bash +cd .. # back to project root directory +cd frontend +npm install +npm run build +``` + +Then use `npm link` to link the frontend library to the example project: + +```bash +cd frontend +npm link +``` + +Go to the example project to finish the link with the frontend library: + +```bash +cd .. # back to project root directory +cd example +npm link django-ai-assistant-client +``` + +Run the example Webpack devserver to build the React frontend: + +```bash +# in example directory +npm install +npm run start +``` + +Install the example project Python dependencies: + +```bash +cd .. # back to project root directory +poetry install +``` + +Create a `.env` file at the example directory: + +```bash +# in example directory +cp .env.example .env +``` + +Fill the `.env` file with the necessary API keys. You'll need accounts on: + +- [OpenAI](https://platform.openai.com/) +- [Weather](https://www.weatherapi.com/) +- [Tavily](https://app.tavily.com/) +- [Firecrawl](https://www.firecrawl.dev/) + +Run Django migrations: + +```bash +# in example directory +python manage.py migrate +``` + +Create a superuser: + +```bash +# in example directory +python manage.py createsuperuser +``` + +Run the Django server: + +```bash +# in example directory +python manage.py runserver +``` + +Access the Django admin at `http://localhost:8000/admin/` and log in with the superuser account. + +## Usage + +Access the example project at `http://localhost:8000/`. + diff --git a/example/assets/js/App.tsx b/example/assets/js/App.tsx index d151019..7adfbd5 100644 --- a/example/assets/js/App.tsx +++ b/example/assets/js/App.tsx @@ -16,7 +16,7 @@ const theme = createTheme({}); // Relates to path("ai-assistant/", include("django_ai_assistant.urls")) // which can be found at example/demo/urls.py) -configAIAssistant({ baseURL: "ai-assistant" }); +configAIAssistant({ BASE: "ai-assistant" }); const ExampleIndex = () => { return ( diff --git a/example/demo/views.py b/example/demo/views.py index 2ec59bd..58d5242 100644 --- a/example/demo/views.py +++ b/example/demo/views.py @@ -29,12 +29,7 @@ def get_assistant_id(self, **kwargs): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - threads = list( - get_threads( - user=self.request.user, - request=self.request, - ) - ) + threads = list(get_threads(user=self.request.user)) context.update( { "assistant_id": self.get_assistant_id(**kwargs), @@ -68,9 +63,11 @@ class AIAssistantChatThreadView(BaseAIAssistantView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) + thread_id = self.kwargs["thread_id"] + thread = get_object_or_404(Thread, id=thread_id) thread_messages = get_thread_messages( - thread_id=self.kwargs["thread_id"], + thread=thread, user=self.request.user, request=self.request, ) diff --git a/example/example/settings.py b/example/example/settings.py index 2c15d3c..e6f28ad 100644 --- a/example/example/settings.py +++ b/example/example/settings.py @@ -158,6 +158,7 @@ # django-ai-assistant +AI_ASSISTANT_INIT_API_FN = "django_ai_assistant.api.views.init_api" AI_ASSISTANT_CAN_CREATE_THREAD_FN = "django_ai_assistant.permissions.allow_all" AI_ASSISTANT_CAN_VIEW_THREAD_FN = "django_ai_assistant.permissions.owns_thread" AI_ASSISTANT_CAN_UPDATE_THREAD_FN = "django_ai_assistant.permissions.owns_thread" diff --git a/example/package-lock.json b/example/package-lock.json index c350159..0e74ca7 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -12,7 +12,6 @@ "@mantine/hooks": "^7.9.2", "@tabler/icons-react": "^3.4.0", "cookie": "^0.6.0", - "django-ai-assistant-client": "file:../frontend", "modern-normalize": "^2.0.0", "react-markdown": "^9.0.1", "react-router-dom": "^6.23.1" @@ -49,14 +48,17 @@ "../frontend": { "name": "django-ai-assistant-client", "version": "0.0.1", + "extraneous": true, "license": "MIT", "dependencies": { - "axios": "^1.7.2" + "axios": "^1.7.2", + "cookie": "^0.6.0" }, "devDependencies": { "@hey-api/openapi-ts": "^0.46.3", "@testing-library/dom": "^10.1.0", "@testing-library/react": "^16.0.0", + "@types/cookie": "^0.6.0", "@types/jest": "^29.5.12", "@types/node": "^20.14.1", "@types/react": "^18.3.3", @@ -4445,10 +4447,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/django-ai-assistant-client": { - "resolved": "../frontend", - "link": true - }, "node_modules/dns-packet": { "version": "5.6.1", "dev": true, diff --git a/example/package.json b/example/package.json index fd6c27d..6ec3487 100644 --- a/example/package.json +++ b/example/package.json @@ -43,9 +43,8 @@ "@mantine/hooks": "^7.9.2", "@tabler/icons-react": "^3.4.0", "cookie": "^0.6.0", - "django-ai-assistant-client": "file:../frontend", "modern-normalize": "^2.0.0", "react-markdown": "^9.0.1", "react-router-dom": "^6.23.1" } -} +} \ No newline at end of file diff --git a/frontend/src/config.ts b/frontend/src/config.ts index a82dc96..d1aded3 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -1,27 +1,38 @@ import cookie from "cookie"; -import { OpenAPI } from "./client"; +import { OpenAPI, OpenAPIConfig } from "./client"; import { AxiosRequestConfig } from "axios"; /** - * Configures the base URL for the AI Assistant API which is path associated with - * the Django include. + * Configures the AI Assistant client, such as setting the base URL (which is + * associated with the Django path include) and request interceptors. * - * Configures the Axios request to include the CSRF token if it exists. + * By default, this function will add a request interceptor to include the CSRF token + * in the request headers if it exists. You can override the default request interceptor + * by providing your own request interceptor function in the configuration object. * - * @param baseURL Base URL of the AI Assistant API. + * NOTE: This function must be called in the root of your application before any + * requests are made to the AI Assistant API. + * + * @param props An `OpenAPIConfig` object containing configuration options for the OpenAPI client. * * @example - * configAIAssistant({ baseURL: "ai-assistant" }); + * configAIAssistant({ BASE: "ai-assistant" }); */ -export function configAIAssistant({ baseURL }: { baseURL: string }) { - OpenAPI.BASE = baseURL; - - OpenAPI.interceptors.request.use((request: AxiosRequestConfig) => { +export function configAIAssistant(props: OpenAPIConfig): OpenAPIConfig { + function defaultRequestInterceptor(request: AxiosRequestConfig) { const { csrftoken } = cookie.parse(document.cookie); if (request.headers && csrftoken) { request.headers["X-CSRFTOKEN"] = csrftoken; } return request; - }); + } + + OpenAPI.interceptors.request.use(defaultRequestInterceptor); + + // Apply the configuration options to the OpenAPI client, and allow the user + // to override the default request interceptor. + Object.assign(OpenAPI, props); + + return OpenAPI; } diff --git a/mkdocs.yml b/mkdocs.yml index 84f413e..272a392 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -7,6 +7,11 @@ edit_uri: blob/main/docs/ theme: name: material + icon: + logo: material/robot-happy-outline + favicon: images/robot-happy-outline.svg + features: + - navigation.tabs palette: # Palette toggle for automatic mode - media: "(prefers-color-scheme)" @@ -41,8 +46,28 @@ markdown_extensions: - toc: permalink: true - attr_list + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg nav: - Home: index.md - Get Started: get-started.md - Tutorial: tutorial.md + - Reference: + - helpers.assistants: assistants-ref.md + - helpers.use_cases: use-cases-ref.md + - models: models-ref.md + - Contributing: contributing.md + +plugins: + - mkdocstrings: + handlers: + python: + options: + show_source: true + show_root_members_full_path: false + show_symbol_type_heading: true + show_symbol_type_toc: true + members_order: source + modernize_annotations: true diff --git a/poetry.lock b/poetry.lock index ececabf..dde1ce6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. [[package]] name = "aiohttp" @@ -816,6 +816,20 @@ files = [ docs = ["Sphinx", "furo"] test = ["objgraph", "psutil"] +[[package]] +name = "griffe" +version = "0.47.0" +description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." +optional = false +python-versions = ">=3.8" +files = [ + {file = "griffe-0.47.0-py3-none-any.whl", hash = "sha256:07a2fd6a8c3d21d0bbb0decf701d62042ccc8a576645c7f8799fe1f10de2b2de"}, + {file = "griffe-0.47.0.tar.gz", hash = "sha256:95119a440a3c932b13293538bdbc405bee4c36428547553dc6b327e7e7d35e5a"}, +] + +[package.dependencies] +colorama = ">=0.4" + [[package]] name = "h11" version = "0.14.0" @@ -1449,6 +1463,22 @@ watchdog = ">=2.0" i18n = ["babel (>=2.9.0)"] min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-import (==1.0)", "importlib-metadata (==4.4)", "jinja2 (==2.11.1)", "markdown (==3.3.6)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "mkdocs-get-deps (==0.2.0)", "packaging (==20.5)", "pathspec (==0.11.1)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "watchdog (==2.0)"] +[[package]] +name = "mkdocs-autorefs" +version = "1.0.1" +description = "Automatically link across pages in MkDocs." +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocs_autorefs-1.0.1-py3-none-any.whl", hash = "sha256:aacdfae1ab197780fb7a2dac92ad8a3d8f7ca8049a9cbe56a4218cd52e8da570"}, + {file = "mkdocs_autorefs-1.0.1.tar.gz", hash = "sha256:f684edf847eced40b570b57846b15f0bf57fb93ac2c510450775dcf16accb971"}, +] + +[package.dependencies] +Markdown = ">=3.3" +markupsafe = ">=2.0.1" +mkdocs = ">=1.1" + [[package]] name = "mkdocs-get-deps" version = "0.2.0" @@ -1505,6 +1535,48 @@ files = [ {file = "mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443"}, ] +[[package]] +name = "mkdocstrings" +version = "0.25.1" +description = "Automatic documentation from sources, for MkDocs." +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocstrings-0.25.1-py3-none-any.whl", hash = "sha256:da01fcc2670ad61888e8fe5b60afe9fee5781017d67431996832d63e887c2e51"}, + {file = "mkdocstrings-0.25.1.tar.gz", hash = "sha256:c3a2515f31577f311a9ee58d089e4c51fc6046dbd9e9b4c3de4c3194667fe9bf"}, +] + +[package.dependencies] +click = ">=7.0" +Jinja2 = ">=2.11.1" +Markdown = ">=3.3" +MarkupSafe = ">=1.1" +mkdocs = ">=1.4" +mkdocs-autorefs = ">=0.3.1" +mkdocstrings-python = {version = ">=0.5.2", optional = true, markers = "extra == \"python\""} +platformdirs = ">=2.2.0" +pymdown-extensions = ">=6.3" + +[package.extras] +crystal = ["mkdocstrings-crystal (>=0.3.4)"] +python = ["mkdocstrings-python (>=0.5.2)"] +python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"] + +[[package]] +name = "mkdocstrings-python" +version = "1.10.5" +description = "A Python handler for mkdocstrings." +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocstrings_python-1.10.5-py3-none-any.whl", hash = "sha256:92e3c588ef1b41151f55281d075de7558dd8092e422cb07a65b18ee2b0863ebb"}, + {file = "mkdocstrings_python-1.10.5.tar.gz", hash = "sha256:acdc2a98cd9d46c7ece508193a16ca03ccabcb67520352b7449f84b57c162bdf"}, +] + +[package.dependencies] +griffe = ">=0.47" +mkdocstrings = ">=0.25" + [[package]] name = "model-bakery" version = "1.18.1" @@ -2204,7 +2276,6 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -3099,4 +3170,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = ">=3.12,<3.13" -content-hash = "138a368288a03b6c2d1a9a71a148bdfd7a037bfdf93995144c47b5e228d0aa5f" +content-hash = "7f3272902fe808e11b024a0508b7b8e8dbff4f30ce2bca50b5cd4971b1d029e7" diff --git a/pyproject.toml b/pyproject.toml index b4e58e3..7b48fdc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ markdown = "^3.6" pygments = "^2.18.0" model-bakery = "^1.18.1" pytest-cov = "^5.0.0" +mkdocstrings = {extras = ["python"], version = "^0.25.1"} [tool.poetry.group.example.dependencies] django-webpack-loader = "^3.1.0" diff --git a/tests/settings.py b/tests/settings.py index 512afb9..0036074 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -107,6 +107,7 @@ # django-ai-assistant # NOTE: set a OPENAI_API_KEY on .env.tests file at root when updating the VCRs. +AI_ASSISTANT_INIT_API_FN = "django_ai_assistant.api.views.init_api" AI_ASSISTANT_CAN_CREATE_THREAD_FN = "django_ai_assistant.permissions.allow_all" AI_ASSISTANT_CAN_VIEW_THREAD_FN = "django_ai_assistant.permissions.owns_thread" AI_ASSISTANT_CAN_UPDATE_THREAD_FN = "django_ai_assistant.permissions.owns_thread" diff --git a/tests/test_helpers/test_use_cases.py b/tests/test_helpers/test_use_cases.py index f93d0fc..131d19a 100644 --- a/tests/test_helpers/test_use_cases.py +++ b/tests/test_helpers/test_use_cases.py @@ -131,8 +131,8 @@ def test_get_assistants_info_returns_info(): info = use_cases.get_assistants_info(user) - assert info[0].id == "temperature_assistant" - assert info[0].name == "Temperature Assistant" + assert info[0]["id"] == "temperature_assistant" + assert info[0]["name"] == "Temperature Assistant" assert len(info) == 1 @@ -284,7 +284,7 @@ def test_get_thread_messages(): baker.make( Message, message={"type": "human", "data": {"content": "hi"}}, thread=thread, _quantity=3 ) - response = use_cases.get_thread_messages(thread.id, user) + response = use_cases.get_thread_messages(thread, user) assert len(response) == 3 @@ -298,31 +298,11 @@ def test_get_thread_messages_raises_exception_when_user_not_allowed(): ) with pytest.raises(AIUserNotAllowedError) as exc_info: - use_cases.get_thread_messages(thread.id, user) + use_cases.get_thread_messages(thread, user) assert str(exc_info.value) == "User is not allowed to view messages in this thread" -@pytest.mark.django_db(transaction=True) -def test_create_thread_message_as_user(): - user = baker.make(User) - thread = baker.make(Thread, created_by=user) - use_cases.create_thread_message_as_user(thread.id, "Hello, how are you?", user) - - assert Message.objects.filter(thread=thread).count() == 1 - - -@pytest.mark.django_db(transaction=True) -def test_create_thread_message_as_user_raises_exception_when_user_not_allowed(): - user = baker.make(User) - thread = baker.make(Thread) - - with pytest.raises(AIUserNotAllowedError) as exc_info: - use_cases.create_thread_message_as_user(thread.id, "Hello, how are you?", user) - - assert str(exc_info.value) == "User is not allowed to create messages in this thread" - - @pytest.mark.django_db(transaction=True) def test_delete_message(): user = baker.make(User) diff --git a/tests/test_views.py b/tests/test_views.py index acb13aa..e91af61 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -5,12 +5,12 @@ from django.urls import reverse import pytest +from langchain_core.messages import HumanMessage from model_bakery import baker from django_ai_assistant import package_name, version -from django_ai_assistant.exceptions import AIAssistantNotDefinedError, AIUserNotAllowedError -from django_ai_assistant.helpers import use_cases from django_ai_assistant.helpers.assistants import AIAssistant +from django_ai_assistant.langchain.chat_message_histories import DjangoChatMessageHistory from django_ai_assistant.langchain.tools import BaseModel, Field, method_tool from django_ai_assistant.models import Message, Thread from tests.utils import assert_ids @@ -102,12 +102,12 @@ def test_get_assistant_that_exists(authenticated_client): @pytest.mark.django_db() def test_get_assistant_that_does_not_exist(authenticated_client): - with pytest.raises(AIAssistantNotDefinedError): - authenticated_client.get( - reverse( - "django_ai_assistant:assistant_detail", kwargs={"assistant_id": "fake_assistant"} - ) - ) + response = authenticated_client.get( + reverse("django_ai_assistant:assistant_detail", kwargs={"assistant_id": "fake_assistant"}) + ) + + assert response.status_code == HTTPStatus.NOT_FOUND + assert response.json() == {"message": "Assistant with id=fake_assistant not found"} def test_does_not_return_assistant_if_unauthorized(): @@ -255,7 +255,7 @@ def test_cannot_delete_thread_if_unauthorized(): @pytest.mark.django_db(transaction=True) def test_list_thread_messages(authenticated_client): thread = baker.make(Thread, created_by=User.objects.first()) - use_cases.create_thread_message_as_user(thread.id, "Hello", thread.created_by) + DjangoChatMessageHistory(thread.id).add_messages([HumanMessage(content="Hello")]) response = authenticated_client.get( reverse("django_ai_assistant:messages_list_create", kwargs={"thread_id": thread.id}) ) @@ -266,12 +266,13 @@ def test_list_thread_messages(authenticated_client): @pytest.mark.django_db(transaction=True) def test_does_not_list_thread_messages_if_not_thread_user(authenticated_client): - with pytest.raises(AIUserNotAllowedError): - thread = baker.make(Thread) - use_cases.create_thread_message_as_user(thread.id, "Hello", User.objects.create()) - authenticated_client.get( - reverse("django_ai_assistant:messages_list_create", kwargs={"thread_id": thread.id}) - ) + thread = baker.make(Thread, created_by=baker.make(User)) + DjangoChatMessageHistory(thread.id).add_messages([HumanMessage(content="Hello")]) + response = authenticated_client.get( + reverse("django_ai_assistant:messages_list_create", kwargs={"thread_id": thread.id}) + ) + + assert response.status_code == HTTPStatus.FORBIDDEN # POST