django-spire 0.20.3__py3-none-any.whl → 0.21.0__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.
- django_spire/ai/chat/intelligence/decoders/intent_decoder.py +49 -0
- django_spire/ai/chat/intelligence/workflows/chat_workflow.py +19 -53
- django_spire/ai/chat/message_intel.py +15 -7
- django_spire/ai/chat/models.py +6 -5
- django_spire/ai/chat/router.py +105 -0
- django_spire/ai/chat/templates/django_spire/ai/chat/message/message.html +2 -2
- django_spire/ai/chat/tests/test_router/test_base_chat_router.py +110 -0
- django_spire/ai/chat/tests/test_router/test_chat_workflow.py +113 -0
- django_spire/ai/chat/tests/test_router/test_integration.py +147 -0
- django_spire/ai/chat/tests/test_router/test_intent_decoder.py +141 -0
- django_spire/ai/chat/tests/test_router/test_message_intel.py +67 -0
- django_spire/ai/chat/tests/test_router/test_spire_chat_router.py +92 -0
- django_spire/ai/chat/tests/test_urls/test_json_urls.py +1 -1
- django_spire/ai/chat/views/message_request_views.py +5 -3
- django_spire/ai/chat/views/message_response_views.py +2 -2
- django_spire/ai/sms/intelligence/workflows/sms_conversation_workflow.py +8 -8
- django_spire/ai/sms/models.py +1 -1
- django_spire/ai/tests/test_ai.py +1 -1
- django_spire/consts.py +1 -1
- django_spire/core/static/django_spire/js/theme.js +22 -0
- django_spire/knowledge/collection/seeding/seeder.py +2 -2
- django_spire/knowledge/entry/seeding/seeder.py +10 -5
- django_spire/knowledge/intelligence/decoders/entry_decoder.py +4 -1
- django_spire/knowledge/intelligence/intel/message_intel.py +1 -1
- django_spire/knowledge/intelligence/router.py +26 -0
- django_spire/knowledge/intelligence/workflows/knowledge_workflow.py +4 -3
- django_spire/settings.py +13 -6
- {django_spire-0.20.3.dist-info → django_spire-0.21.0.dist-info}/METADATA +1 -1
- {django_spire-0.20.3.dist-info → django_spire-0.21.0.dist-info}/RECORD +32 -24
- django_spire/ai/chat/intelligence/decoders/tools.py +0 -34
- {django_spire-0.20.3.dist-info → django_spire-0.21.0.dist-info}/WHEEL +0 -0
- {django_spire-0.20.3.dist-info → django_spire-0.21.0.dist-info}/licenses/LICENSE.md +0 -0
- {django_spire-0.20.3.dist-info → django_spire-0.21.0.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
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
)
|
|
22
|
+
chat_routers = getattr(settings, 'DJANGO_SPIRE_AI_CHAT_ROUTERS', {
|
|
23
|
+
'SPIRE': 'django_spire.ai.chat.router.SpireChatRouter'
|
|
24
|
+
})
|
|
60
25
|
|
|
61
|
-
|
|
26
|
+
router_path = chat_routers.get(router_key)
|
|
62
27
|
|
|
63
|
-
|
|
28
|
+
if not router_path:
|
|
29
|
+
router_path = 'django_spire.ai.chat.router.SpireChatRouter'
|
|
64
30
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
74
|
-
raise TypeError(message)
|
|
36
|
+
router_instance = router_class()
|
|
75
37
|
|
|
76
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
|
28
|
+
def render_to_str(self) -> str:
|
|
21
29
|
raise NotImplementedError
|
|
22
30
|
|
|
23
|
-
def
|
|
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
|
|
46
|
+
def render_to_str(self) -> str:
|
|
39
47
|
return self.text
|
django_spire/ai/chat/models.py
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
+
)
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
}
|
|
20
20
|
}"
|
|
21
21
|
>
|
|
22
|
-
{{ message_intel.
|
|
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.
|
|
36
|
+
{{ message_intel.render_to_str|linebreaksbr }}
|
|
37
37
|
{% endblock %}
|
|
38
38
|
</div>
|
|
39
39
|
|
|
@@ -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
|