django-spire 0.20.4__py3-none-any.whl → 0.21.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. django_spire/ai/chat/intelligence/decoders/intent_decoder.py +49 -0
  2. django_spire/ai/chat/intelligence/workflows/chat_workflow.py +19 -53
  3. django_spire/ai/chat/message_intel.py +15 -7
  4. django_spire/ai/chat/models.py +6 -5
  5. django_spire/ai/chat/router.py +105 -0
  6. django_spire/ai/chat/templates/django_spire/ai/chat/element/recent_chat_select_element.html +1 -1
  7. django_spire/ai/chat/templates/django_spire/ai/chat/message/message.html +2 -2
  8. django_spire/ai/chat/templates/django_spire/ai/chat/widget/selection_widget.html +1 -1
  9. django_spire/ai/chat/tests/test_router/test_base_chat_router.py +110 -0
  10. django_spire/ai/chat/tests/test_router/test_chat_workflow.py +113 -0
  11. django_spire/ai/chat/tests/test_router/test_integration.py +147 -0
  12. django_spire/ai/chat/tests/test_router/test_intent_decoder.py +141 -0
  13. django_spire/ai/chat/tests/test_router/test_message_intel.py +67 -0
  14. django_spire/ai/chat/tests/test_router/test_spire_chat_router.py +92 -0
  15. django_spire/ai/chat/tests/test_urls/test_json_urls.py +1 -1
  16. django_spire/ai/chat/views/message_request_views.py +5 -3
  17. django_spire/ai/chat/views/message_response_views.py +2 -2
  18. django_spire/ai/sms/intelligence/workflows/sms_conversation_workflow.py +8 -8
  19. django_spire/ai/sms/models.py +1 -1
  20. django_spire/ai/tests/test_ai.py +1 -1
  21. django_spire/consts.py +1 -1
  22. django_spire/core/static/django_spire/css/app-navigation.css +4 -4
  23. django_spire/core/static/django_spire/css/app-side-panel.css +0 -45
  24. django_spire/core/templates/django_spire/navigation/top_navigation.html +42 -38
  25. django_spire/core/templates/django_spire/page/full_page.html +69 -47
  26. django_spire/knowledge/collection/seeding/seeder.py +2 -2
  27. django_spire/knowledge/entry/seeding/seeder.py +10 -5
  28. django_spire/knowledge/intelligence/decoders/entry_decoder.py +4 -1
  29. django_spire/knowledge/intelligence/intel/message_intel.py +1 -1
  30. django_spire/knowledge/intelligence/router.py +26 -0
  31. django_spire/knowledge/intelligence/workflows/knowledge_workflow.py +4 -3
  32. django_spire/knowledge/templates/django_spire/knowledge/sub_navigation/item/entry_sub_navigation_item.html +1 -0
  33. django_spire/settings.py +13 -6
  34. {django_spire-0.20.4.dist-info → django_spire-0.21.1.dist-info}/METADATA +1 -1
  35. {django_spire-0.20.4.dist-info → django_spire-0.21.1.dist-info}/RECORD +38 -30
  36. django_spire/ai/chat/intelligence/decoders/tools.py +0 -34
  37. {django_spire-0.20.4.dist-info → django_spire-0.21.1.dist-info}/WHEEL +0 -0
  38. {django_spire-0.20.4.dist-info → django_spire-0.21.1.dist-info}/licenses/LICENSE.md +0 -0
  39. {django_spire-0.20.4.dist-info → django_spire-0.21.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Callable, TYPE_CHECKING
4
+
5
+ from dandy import Decoder
6
+
7
+ from django_spire.conf import settings
8
+ from django_spire.core.utils import get_callable_from_module_string_and_validate_arguments
9
+
10
+ if TYPE_CHECKING:
11
+ from django.core.handlers.wsgi import WSGIRequest
12
+
13
+
14
+ def generate_intent_decoder(
15
+ request: WSGIRequest,
16
+ default_callable: Callable | None = None
17
+ ) -> Decoder:
18
+ intent_dict = {}
19
+
20
+ if hasattr(settings, 'DJANGO_SPIRE_AI_INTENT_CHAT_ROUTERS'):
21
+ for intent_config in settings.DJANGO_SPIRE_AI_INTENT_CHAT_ROUTERS.values():
22
+ required_permission = intent_config.get('REQUIRED_PERMISSION')
23
+
24
+ if required_permission and not request.user.has_perm(required_permission):
25
+ continue
26
+
27
+ intent_description = intent_config.get('INTENT_DESCRIPTION', '')
28
+ chat_router_path = intent_config.get('CHAT_ROUTER')
29
+
30
+ if chat_router_path:
31
+ try:
32
+ router_class = get_callable_from_module_string_and_validate_arguments(
33
+ chat_router_path,
34
+ []
35
+ )
36
+
37
+ router_instance = router_class()
38
+
39
+ intent_dict[intent_description] = router_instance.workflow
40
+ except ImportError:
41
+ pass
42
+
43
+ if default_callable is not None:
44
+ intent_dict['None of the above choices match the user\'s intent'] = default_callable
45
+
46
+ return Decoder(
47
+ mapping_keys_description='Intent of the User\'s Request',
48
+ mapping=intent_dict
49
+ )
@@ -1,13 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import TYPE_CHECKING, Callable
3
+ from typing import TYPE_CHECKING
4
4
 
5
- from dandy import Bot
6
- from dandy.recorder import recorder_to_html_file
7
-
8
- from django_spire.ai.chat.intelligence.decoders.tools import generate_intent_decoder
9
- from django_spire.ai.chat.message_intel import BaseMessageIntel, DefaultMessageIntel
10
- from django_spire.ai.decorators import log_ai_interaction_from_recorder
11
5
  from django_spire.conf import settings
12
6
  from django_spire.core.utils import get_callable_from_module_string_and_validate_arguments
13
7
 
@@ -15,62 +9,34 @@ if TYPE_CHECKING:
15
9
  from dandy.llm.request.message import MessageHistory
16
10
  from django.core.handlers.wsgi import WSGIRequest
17
11
 
18
-
19
- def default_chat_callable(
20
- request: WSGIRequest,
21
- user_input: str,
22
- message_history: MessageHistory | None = None
23
- ) -> BaseMessageIntel:
24
- bot = Bot()
25
- return bot.llm.prompt_to_intel(
26
- prompt=user_input,
27
- intel_class=DefaultMessageIntel,
28
- message_history=message_history,
29
- )
12
+ from django_spire.ai.chat.message_intel import BaseMessageIntel
30
13
 
31
14
 
32
- @recorder_to_html_file('spire_ai_chat_workflow')
33
15
  def chat_workflow(
34
16
  request: WSGIRequest,
35
17
  user_input: str,
36
18
  message_history: MessageHistory | None = None
37
19
  ) -> BaseMessageIntel:
38
- @log_ai_interaction_from_recorder(request.user)
39
- def run_workflow_process(callable_: Callable) -> BaseMessageIntel | None:
40
- return callable_(
41
- request=request,
42
- user_input=user_input,
43
- message_history=message_history,
44
- )
45
-
46
- if settings.AI_CHAT_CALLABLE is not None:
47
- chat_callable = get_callable_from_module_string_and_validate_arguments(
48
- settings.AI_CHAT_CALLABLE,
49
- ['request', 'user_input', 'message_history']
50
- )
51
-
52
- message_intel = run_workflow_process(chat_callable)
53
-
54
- else:
20
+ router_key = getattr(settings, 'DJANGO_SPIRE_AI_DEFAULT_CHAT_ROUTER', 'SPIRE')
55
21
 
56
- intent_decoder = generate_intent_decoder(
57
- request=request,
58
- default_callable=default_chat_callable,
59
- )
22
+ chat_routers = getattr(settings, 'DJANGO_SPIRE_AI_CHAT_ROUTERS', {
23
+ 'SPIRE': 'django_spire.ai.chat.router.SpireChatRouter'
24
+ })
60
25
 
61
- intent_process = intent_decoder.process(user_input, max_return_values=1)[0]
26
+ router_path = chat_routers.get(router_key)
62
27
 
63
- message_intel = run_workflow_process(intent_process)
28
+ if not router_path:
29
+ router_path = 'django_spire.ai.chat.router.SpireChatRouter'
64
30
 
65
- if not isinstance(message_intel, BaseMessageIntel):
66
- if message_intel is None:
67
- return default_chat_callable(
68
- request=request,
69
- user_input=user_input,
70
- message_history=message_history
71
- )
31
+ router_class = get_callable_from_module_string_and_validate_arguments(
32
+ router_path,
33
+ []
34
+ )
72
35
 
73
- message = f'{intent_process.__qualname__} must return an instance of a {BaseMessageIntel.__name__} sub class.'
74
- raise TypeError(message)
36
+ router_instance = router_class()
75
37
 
76
- return message_intel
38
+ return router_instance.process(
39
+ request=request,
40
+ user_input=user_input,
41
+ message_history=message_history,
42
+ )
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from abc import abstractmethod, ABC
3
+ from abc import ABC, abstractmethod
4
4
 
5
5
  from dandy import BaseIntel
6
6
  from django.template.loader import render_to_string
@@ -9,18 +9,26 @@ from django.template.loader import render_to_string
9
9
  class BaseMessageIntel(BaseIntel, ABC):
10
10
  _template: str
11
11
 
12
- def __init_subclass__(cls):
13
- super().__init_subclass__()
12
+ def __init_subclass__(cls, **kwargs):
13
+ super().__init_subclass__(**kwargs)
14
14
 
15
- if cls._template is None or cls._template == '':
15
+ template = cls.__dict__.get('_template')
16
+
17
+ if template is None and hasattr(cls, '__private_attributes__'):
18
+ private_attr = cls.__private_attributes__.get('_template')
19
+
20
+ if private_attr and hasattr(private_attr, 'default'):
21
+ template = private_attr.default
22
+
23
+ if not template:
16
24
  message = f'{cls.__module__}.{cls.__qualname__}._template must be set'
17
25
  raise ValueError(message)
18
26
 
19
27
  @abstractmethod
20
- def content_to_str(self) -> str:
28
+ def render_to_str(self) -> str:
21
29
  raise NotImplementedError
22
30
 
23
- def render_to_string(self, context_data: dict | None = None):
31
+ def render_template_to_str(self, context_data: dict | None = None):
24
32
  return render_to_string(
25
33
  template_name=self._template,
26
34
  context={**self.model_dump(), **(context_data or {})},
@@ -35,5 +43,5 @@ class DefaultMessageIntel(BaseMessageIntel):
35
43
  _template: str = 'django_spire/ai/chat/message/default_message.html'
36
44
  text: str
37
45
 
38
- def content_to_str(self) -> str:
46
+ def render_to_str(self) -> str:
39
47
  return self.text
@@ -4,11 +4,12 @@ from dandy.llm.request.message import MessageHistory, RoleLiteralStr
4
4
  from django.contrib.auth.models import User
5
5
  from django.db import models
6
6
  from django.utils.timezone import now
7
+ from pydantic import ValidationError
7
8
 
9
+ from django_spire.ai.chat.choices import MessageResponseType
8
10
  from django_spire.ai.chat.message_intel import BaseMessageIntel, DefaultMessageIntel
11
+ from django_spire.ai.chat.querysets import ChatMessageQuerySet, ChatQuerySet
9
12
  from django_spire.ai.chat.responses import MessageResponse
10
- from django_spire.ai.chat.choices import MessageResponseType
11
- from django_spire.ai.chat.querysets import ChatQuerySet, ChatMessageQuerySet
12
13
  from django_spire.history.mixins import HistoryModelMixin
13
14
  from django_spire.utils import get_class_from_string, get_class_name_from_class
14
15
 
@@ -64,7 +65,7 @@ class Chat(HistoryModelMixin):
64
65
  for message in messages:
65
66
  message_history.add_message(
66
67
  role=message.role,
67
- content=message.intel.content_to_str()
68
+ content=message.intel.render_to_str()
68
69
  )
69
70
 
70
71
  return message_history
@@ -105,7 +106,7 @@ class ChatMessage(HistoryModelMixin):
105
106
  objects = ChatMessageQuerySet.as_manager()
106
107
 
107
108
  def __str__(self):
108
- content = self.intel.content_to_str()
109
+ content = self.intel.render_to_str()
109
110
 
110
111
  if len(content) < 64:
111
112
  return content
@@ -118,7 +119,7 @@ class ChatMessage(HistoryModelMixin):
118
119
  intel_class: type[BaseMessageIntel] = get_class_from_string(self._intel_class_name)
119
120
  return intel_class.model_validate(self._intel_data)
120
121
 
121
- except ImportError:
122
+ except (ImportError, ValidationError):
122
123
  intel_class: type[BaseMessageIntel] = DefaultMessageIntel
123
124
  return intel_class.model_validate(
124
125
  {'text': str(self._intel_data)}
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import TYPE_CHECKING
5
+
6
+ from dandy import Bot, Prompt
7
+ from dandy.recorder import recorder_to_html_file
8
+
9
+ from django_spire.ai.chat.intelligence.decoders.intent_decoder import generate_intent_decoder
10
+ from django_spire.ai.chat.message_intel import BaseMessageIntel, DefaultMessageIntel
11
+ from django_spire.ai.decorators import log_ai_interaction_from_recorder
12
+ from django_spire.conf import settings
13
+
14
+ if TYPE_CHECKING:
15
+ from dandy.llm.request.message import MessageHistory
16
+ from django.core.handlers.wsgi import WSGIRequest
17
+
18
+
19
+ class BaseChatRouter(ABC):
20
+ @abstractmethod
21
+ def workflow(
22
+ self,
23
+ request: WSGIRequest,
24
+ user_input: str,
25
+ message_history: MessageHistory | None = None
26
+ ) -> BaseMessageIntel:
27
+ raise NotImplementedError
28
+
29
+ @recorder_to_html_file('spire_ai_chat_workflow')
30
+ def process(
31
+ self,
32
+ request: WSGIRequest,
33
+ user_input: str,
34
+ message_history: MessageHistory | None = None
35
+ ) -> BaseMessageIntel:
36
+ @log_ai_interaction_from_recorder(request.user)
37
+ def run_workflow_process() -> BaseMessageIntel | None:
38
+ return self.workflow(
39
+ request=request,
40
+ user_input=user_input,
41
+ message_history=message_history,
42
+ )
43
+
44
+ message_intel = run_workflow_process()
45
+
46
+ if not isinstance(message_intel, BaseMessageIntel):
47
+ if message_intel is None:
48
+ return DefaultMessageIntel(
49
+ text='I apologize, but I was unable to process your request.'
50
+ )
51
+
52
+ message = f'{self.__class__.__name__}.workflow must return an instance of a {BaseMessageIntel.__name__} sub class.'
53
+ raise TypeError(message)
54
+
55
+ return message_intel
56
+
57
+
58
+ class SpireChatRouter(BaseChatRouter):
59
+ def _default_chat_callable(
60
+ self,
61
+ request: WSGIRequest,
62
+ user_input: str,
63
+ message_history: MessageHistory | None = None
64
+ ) -> BaseMessageIntel:
65
+ persona_name = getattr(settings, 'DJANGO_SPIRE_AI_PERSONA_NAME', 'AI Assistant')
66
+
67
+ system_prompt = (
68
+ Prompt()
69
+ .text(f'You are {persona_name}, a helpful AI assistant.')
70
+ .line_break()
71
+ .text('Important rules:')
72
+ .list([
73
+ f'You should always identify yourself as {persona_name}.',
74
+ 'Please never mention being Qwen, Claude, GPT, or any other model name.',
75
+ 'Be helpful, friendly, and professional.',
76
+ ])
77
+ )
78
+
79
+ bot = Bot()
80
+ bot.llm_role = system_prompt
81
+
82
+ return bot.llm.prompt_to_intel(
83
+ prompt=user_input,
84
+ intel_class=DefaultMessageIntel,
85
+ message_history=message_history,
86
+ )
87
+
88
+ def workflow(
89
+ self,
90
+ request: WSGIRequest,
91
+ user_input: str,
92
+ message_history: MessageHistory | None = None
93
+ ) -> BaseMessageIntel:
94
+ intent_decoder = generate_intent_decoder(
95
+ request=request,
96
+ default_callable=self._default_chat_callable,
97
+ )
98
+
99
+ intent_process = intent_decoder.process(user_input, max_return_values=1)[0]
100
+
101
+ return intent_process(
102
+ request=request,
103
+ user_input=user_input,
104
+ message_history=message_history
105
+ )
@@ -77,7 +77,7 @@
77
77
  ></i>
78
78
 
79
79
  <template x-if="!can_rename_chat">
80
- <div @click="$dispatch('load-chat', {chat_id: {{ recent_chat.id }}})" class="cursor-pointer flex-grow-1 text-truncate">
80
+ <div @click="$dispatch('load-chat', {chat_id: {{ recent_chat.id }}})" class="cursor-pointer flex-grow-1 text-truncate" data-closes-nav>
81
81
  <span class="px-2 fs-7" x-text="chat_name"></span>
82
82
  </div>
83
83
  </template>
@@ -19,7 +19,7 @@
19
19
  }
20
20
  }"
21
21
  >
22
- {{ message_intel.content_to_str|json_script:message_intel_id }}
22
+ {{ message_intel.render_to_str|json_script:message_intel_id }}
23
23
 
24
24
  <div class="px-1 text-muted {{ sender_class }}" style="font-size: 0.7rem;">
25
25
  {% block message_sender %}
@@ -33,7 +33,7 @@
33
33
 
34
34
  <div class="d-inline-block px-2 py-1 rounded-3 shadow-sm {{ content_class|default:'border' }}">
35
35
  {% block message_content %}
36
- {{ message_intel.content_to_str|linebreaksbr }}
36
+ {{ message_intel.render_to_str|linebreaksbr }}
37
37
  {% endblock %}
38
38
  </div>
39
39
 
@@ -21,7 +21,7 @@
21
21
 
22
22
  <div class="col-12 px-0">
23
23
  <div class="px-2 pb-3">
24
- <button @click="$dispatch('load-chat', {chat_id: 0})" class="btn btn-app-primary-outlined p-1 w-100">
24
+ <button @click="$dispatch('load-chat', {chat_id: 0})" class="btn btn-app-primary-outlined p-1 w-100" data-closes-nav>
25
25
  <i class="bi bi-plus-lg me-2"></i>New Chat
26
26
  </button>
27
27
  </div>
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from django.test import RequestFactory
8
+
9
+ from django_spire.ai.chat.message_intel import BaseMessageIntel, DefaultMessageIntel
10
+ from django_spire.ai.chat.router import BaseChatRouter
11
+ from django_spire.core.tests.test_cases import BaseTestCase
12
+
13
+ if TYPE_CHECKING:
14
+ from dandy.llm.request.message import MessageHistory
15
+ from django.core.handlers.wsgi import WSGIRequest
16
+
17
+
18
+ class TestBaseChatRouter(BaseTestCase):
19
+ def setUp(self) -> None:
20
+ super().setUp()
21
+
22
+ self.factory = RequestFactory()
23
+ self.request = self.factory.get('/')
24
+ self.request.user = self.super_user
25
+
26
+ def test_workflow_is_abstract_method(self) -> None:
27
+ with pytest.raises(TypeError):
28
+ BaseChatRouter()
29
+
30
+ def test_process_calls_workflow(self) -> None:
31
+ class TestRouter(BaseChatRouter):
32
+ workflow_called = False
33
+
34
+ def workflow(
35
+ self,
36
+ request: WSGIRequest,
37
+ user_input: str,
38
+ message_history: MessageHistory | None = None
39
+ ) -> BaseMessageIntel:
40
+ self.workflow_called = True
41
+ return DefaultMessageIntel(text='Test response')
42
+
43
+ router = TestRouter()
44
+ result = router.process(
45
+ request=self.request,
46
+ user_input='Hello',
47
+ message_history=None
48
+ )
49
+
50
+ assert router.workflow_called
51
+ assert isinstance(result, DefaultMessageIntel)
52
+ assert result.text == 'Test response'
53
+
54
+ def test_process_validates_workflow_return_type(self) -> None:
55
+ class InvalidRouter(BaseChatRouter):
56
+ def workflow(self, request, user_input, message_history=None) -> str:
57
+ return 'Invalid return type'
58
+
59
+ router = InvalidRouter()
60
+
61
+ with pytest.raises(TypeError) as cm:
62
+ router.process(
63
+ request=self.request,
64
+ user_input='Hello',
65
+ message_history=None
66
+ )
67
+
68
+ assert 'BaseMessageIntel' in str(cm.value)
69
+
70
+ def test_process_handles_none_return(self) -> None:
71
+ class NoneRouter(BaseChatRouter):
72
+ def workflow(self, request, user_input, message_history=None) -> None:
73
+ return None
74
+
75
+ router = NoneRouter()
76
+ result = router.process(
77
+ request=self.request,
78
+ user_input='Hello',
79
+ message_history=None
80
+ )
81
+
82
+ assert isinstance(result, DefaultMessageIntel)
83
+ assert result.text == 'I apologize, but I was unable to process your request.'
84
+
85
+ def test_process_accepts_all_parameters(self) -> None:
86
+ from dandy.llm.request.message import MessageHistory
87
+
88
+ class ParamTestRouter(BaseChatRouter):
89
+ received_params = {}
90
+
91
+ def workflow(self, request, user_input, message_history=None) -> DefaultMessageIntel:
92
+ self.received_params = {
93
+ 'request': request,
94
+ 'user_input': user_input,
95
+ 'message_history': message_history
96
+ }
97
+ return DefaultMessageIntel(text='Success')
98
+
99
+ router = ParamTestRouter()
100
+ message_history = MessageHistory()
101
+
102
+ router.process(
103
+ request=self.request,
104
+ user_input='Test input',
105
+ message_history=message_history
106
+ )
107
+
108
+ assert router.received_params['request'] == self.request
109
+ assert router.received_params['user_input'] == 'Test input'
110
+ assert router.received_params['message_history'] == message_history
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ from unittest.mock import Mock, patch
4
+
5
+ from django.test import RequestFactory, override_settings
6
+
7
+ from django_spire.ai.chat.intelligence.workflows.chat_workflow import chat_workflow
8
+ from django_spire.ai.chat.message_intel import DefaultMessageIntel
9
+ from django_spire.ai.chat.router import BaseChatRouter
10
+ from django_spire.core.tests.test_cases import BaseTestCase
11
+
12
+
13
+ class MockRouter(BaseChatRouter):
14
+ def workflow(self, request, user_input, message_history=None) -> DefaultMessageIntel:
15
+ return DefaultMessageIntel(text='Mock response')
16
+
17
+
18
+ class TestChatWorkflow(BaseTestCase):
19
+ def setUp(self) -> None:
20
+ super().setUp()
21
+
22
+ self.factory = RequestFactory()
23
+ self.request = self.factory.get('/')
24
+ self.request.user = self.super_user
25
+
26
+ @override_settings(
27
+ DJANGO_SPIRE_AI_DEFAULT_CHAT_ROUTER='SPIRE',
28
+ DJANGO_SPIRE_AI_CHAT_ROUTERS={
29
+ 'SPIRE': 'django_spire.ai.chat.router.SpireChatRouter'
30
+ }
31
+ )
32
+ def test_workflow_loads_default_router(self) -> None:
33
+ with patch('django_spire.ai.chat.intelligence.workflows.chat_workflow.get_callable_from_module_string_and_validate_arguments') as mock_get_callable:
34
+ mock_router_class = Mock()
35
+ mock_router_instance = Mock()
36
+ mock_router_instance.process.return_value = DefaultMessageIntel(text='Response')
37
+ mock_router_class.return_value = mock_router_instance
38
+ mock_get_callable.return_value = mock_router_class
39
+
40
+ result = chat_workflow(
41
+ request=self.request,
42
+ user_input='Hello',
43
+ message_history=None
44
+ )
45
+
46
+ mock_get_callable.assert_called_once_with(
47
+ 'django_spire.ai.chat.router.SpireChatRouter',
48
+ []
49
+ )
50
+
51
+ assert isinstance(result, DefaultMessageIntel)
52
+
53
+ @override_settings(
54
+ DJANGO_SPIRE_AI_DEFAULT_CHAT_ROUTER='CUSTOM',
55
+ DJANGO_SPIRE_AI_CHAT_ROUTERS={
56
+ 'CUSTOM': 'django_spire.ai.chat.tests.test_router.test_chat_workflow.MockRouter'
57
+ }
58
+ )
59
+ def test_workflow_loads_custom_router(self) -> None:
60
+ result = chat_workflow(
61
+ request=self.request,
62
+ user_input='Hello',
63
+ message_history=None
64
+ )
65
+
66
+ assert isinstance(result, DefaultMessageIntel)
67
+ assert result.text == 'Mock response'
68
+
69
+ @override_settings(
70
+ DJANGO_SPIRE_AI_DEFAULT_CHAT_ROUTER='NONEXISTENT',
71
+ DJANGO_SPIRE_AI_CHAT_ROUTERS={
72
+ 'SPIRE': 'django_spire.ai.chat.router.SpireChatRouter'
73
+ }
74
+ )
75
+ def test_workflow_falls_back_when_router_not_found(self) -> None:
76
+ with patch('django_spire.ai.chat.intelligence.workflows.chat_workflow.get_callable_from_module_string_and_validate_arguments') as mock_get_callable:
77
+ mock_router_class = Mock()
78
+ mock_router_instance = Mock()
79
+ mock_router_instance.process.return_value = DefaultMessageIntel(text='Fallback')
80
+ mock_router_class.return_value = mock_router_instance
81
+ mock_get_callable.return_value = mock_router_class
82
+
83
+ result = chat_workflow(
84
+ request=self.request,
85
+ user_input='Hello',
86
+ message_history=None
87
+ )
88
+
89
+ assert isinstance(result, DefaultMessageIntel)
90
+
91
+ @override_settings(
92
+ DJANGO_SPIRE_AI_DEFAULT_CHAT_ROUTER='TEST',
93
+ DJANGO_SPIRE_AI_CHAT_ROUTERS={
94
+ 'TEST': 'django_spire.ai.chat.tests.test_router.test_chat_workflow.MockRouter'
95
+ }
96
+ )
97
+ def test_workflow_passes_message_history(self) -> None:
98
+ from dandy.llm.request.message import MessageHistory
99
+
100
+ message_history = MessageHistory()
101
+
102
+ with patch('django_spire.ai.chat.tests.test_router.test_chat_workflow.MockRouter.process') as mock_process:
103
+ mock_process.return_value = DefaultMessageIntel(text='Response')
104
+
105
+ chat_workflow(
106
+ request=self.request,
107
+ user_input='Hello',
108
+ message_history=message_history
109
+ )
110
+
111
+ mock_process.assert_called_once()
112
+ call_kwargs = mock_process.call_args[1]
113
+ assert call_kwargs['message_history'] == message_history