From 3bbefcb710caf7c940bb8d7eb5ac31d2d787992c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fl=C3=A1vio=20Juvenal?= Date: Thu, 20 Jun 2024 12:45:07 -0300 Subject: [PATCH 1/3] Reference documentation --- django_ai_assistant/api/views.py | 20 ++- django_ai_assistant/exceptions.py | 6 + django_ai_assistant/helpers/assistants.py | 35 +++-- django_ai_assistant/helpers/use_cases.py | 181 ++++++++++++++++++---- docs/assistants-ref.md | 7 + docs/index.md | 21 ++- docs/tutorial.md | 10 +- docs/use-cases-ref.md | 3 + example/demo/views.py | 4 +- mkdocs.yml | 25 ++- poetry.lock | 77 ++++++++- pyproject.toml | 1 + tests/test_helpers/test_use_cases.py | 28 +--- tests/test_views.py | 31 ++-- 14 files changed, 335 insertions(+), 114 deletions(-) create mode 100644 docs/assistants-ref.md create mode 100644 docs/use-cases-ref.md diff --git a/django_ai_assistant/api/views.py b/django_ai_assistant/api/views.py index b5a19bb..77b888a 100644 --- a/django_ai_assistant/api/views.py +++ b/django_ai_assistant/api/views.py @@ -16,7 +16,7 @@ ThreadSchema, ThreadSchemaIn, ) -from django_ai_assistant.exceptions import AIUserNotAllowedError +from django_ai_assistant.exceptions import AIAssistantNotDefinedError, AIUserNotAllowedError from django_ai_assistant.helpers import use_cases from django_ai_assistant.models import Message, Thread @@ -47,6 +47,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 +70,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 +86,7 @@ def get_thread(request, thread_id: str): 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 @@ -101,9 +110,8 @@ def delete_thread(request, thread_id: str): url_name="messages_list_create", ) def list_thread_messages(request, thread_id: str): - messages = use_cases.get_thread_messages( - thread_id=thread_id, user=request.user, request=request - ) + 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/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 8a89cc0..8ccc1e1 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: int | None) -> Runnable[dict, dict]: return agent_with_chat_history - def invoke(self, *args, thread_id: int | None, **kwargs): + def invoke(self, *args: Any, thread_id: int | 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: int | None, **kwargs): chain = self.as_chain(thread_id) return chain.invoke(*args, **kwargs) - def run(self, message, thread_id: int | None, **kwargs): + def run(self, message: str, thread_id: int | 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: int | None, **kwargs): **kwargs, )["output"] - def _run_as_tool(self, message: str, **kwargs): + def _run_as_tool(self, message: str, **kwargs: Any): 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 98f7b38..628be1a 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: str, 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)) @@ -120,7 +205,20 @@ def update_thread( user: Any, request: HttpRequest | None = None, ): - if not can_delete_thread(thread=thread, user=user, request=request): + """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: str, + 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: str, - 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/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/index.md b/docs/index.md index 8e3464f..f7fa6bd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,24 +1,21 @@ # Django AI Assistant -Implement powerful AI Assistants using Django. Combine the power of Large Language Models with Django's productivity. -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. +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/tutorial.md b/docs/tutorial.md index e1ab309..7763db8 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -215,15 +215,14 @@ 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 @@ -396,5 +395,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/demo/views.py b/example/demo/views.py index 2ec59bd..13ac0cf 100644 --- a/example/demo/views.py +++ b/example/demo/views.py @@ -68,9 +68,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/mkdocs.yml b/mkdocs.yml index 84f413e..1c55609 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -7,6 +7,8 @@ edit_uri: blob/main/docs/ theme: name: material + features: + - navigation.tabs palette: # Palette toggle for automatic mode - media: "(prefers-color-scheme)" @@ -43,6 +45,23 @@ markdown_extensions: - attr_list nav: - - Home: index.md - - Get Started: get-started.md - - Tutorial: tutorial.md + - Home: + - Overview: index.md + - Usage: + - Get Started: get-started.md + - Tutorial: tutorial.md + - Reference: + - helpers.assistants: assistants-ref.md + - helpers.use_cases: use-cases-ref.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/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 19d64b1..d1962d5 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -4,12 +4,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 @@ -100,12 +100,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(): @@ -253,7 +253,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}) ) @@ -264,12 +264,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 From e68c0090507cfc1d6f0413b1ab5c08e030fb1917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fl=C3=A1vio=20Juvenal?= Date: Thu, 20 Jun 2024 14:01:17 -0300 Subject: [PATCH 2/3] Update _run_as_tool return type Co-authored-by: Pamella Bezerra --- django_ai_assistant/helpers/assistants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django_ai_assistant/helpers/assistants.py b/django_ai_assistant/helpers/assistants.py index 8ccc1e1..ff6b8a3 100644 --- a/django_ai_assistant/helpers/assistants.py +++ b/django_ai_assistant/helpers/assistants.py @@ -554,7 +554,7 @@ def run(self, message: str, thread_id: int | None, **kwargs: Any) -> str: **kwargs, )["output"] - def _run_as_tool(self, message: str, **kwargs: Any): + def _run_as_tool(self, message: str, **kwargs: Any) -> str: return self.run(message, thread_id=None, **kwargs) def as_tool(self, description: str) -> BaseTool: From 19b49447a4ddf215f21f2f96b3d9778845cec6fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fl=C3=A1vio=20Juvenal?= Date: Thu, 20 Jun 2024 14:03:52 -0300 Subject: [PATCH 3/3] Fix return type of update_thread Co-authored-by: Pamella Bezerra --- django_ai_assistant/helpers/use_cases.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django_ai_assistant/helpers/use_cases.py b/django_ai_assistant/helpers/use_cases.py index 628be1a..9b68e97 100644 --- a/django_ai_assistant/helpers/use_cases.py +++ b/django_ai_assistant/helpers/use_cases.py @@ -204,7 +204,7 @@ def update_thread( name: str, user: Any, request: HttpRequest | None = None, -): +) -> Thread: """Update thread name.\n Uses `AI_ASSISTANT_CAN_UPDATE_THREAD_FN` permission to check if user can update the thread.