django-spire 0.25.1__py3-none-any.whl → 0.26.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/router.py +10 -20
- django_spire/ai/chat/templates/django_spire/ai/chat/dropdown/ellipsis_dropdown.html +5 -3
- django_spire/ai/chat/templates/django_spire/ai/chat/message/default_message.html +6 -2
- django_spire/ai/chat/templatetags/spire_ai_chat_tags.py +19 -0
- django_spire/ai/chat/tests/test_router/test_spire_chat_router.py +20 -88
- django_spire/auth/templates/django_spire/auth/element/android_and_chrome_app_install_element.html +33 -0
- django_spire/auth/templates/django_spire/auth/element/ios_app_install_element.html +48 -0
- django_spire/auth/templates/django_spire/auth/page/auth_page.html +2 -1
- django_spire/auth/templates/django_spire/auth/page/login_page.html +2 -0
- django_spire/comment/mixins.py +3 -3
- django_spire/comment/templates/django_spire/comment/card/comment_list_card.html +8 -5
- django_spire/comment/templates/django_spire/comment/form/comment_form.html +3 -3
- django_spire/comment/templates/django_spire/comment/form/content/comment_form_content.html +1 -0
- django_spire/comment/templates/django_spire/comment/item/comment_item_ellipsis.html +12 -8
- django_spire/comment/views.py +8 -8
- django_spire/consts.py +1 -1
- django_spire/contrib/form/utils.py +3 -3
- django_spire/contrib/progress/session.py +1 -1
- django_spire/contrib/queryset/filter_tools.py +56 -14
- django_spire/contrib/queryset/mixins.py +24 -3
- django_spire/contrib/service/django_model_service.py +5 -6
- django_spire/core/management/commands/spire_startapp.py +42 -25
- django_spire/core/management/commands/spire_startapp_pkg/exceptions.py +5 -0
- django_spire/core/management/commands/spire_startapp_pkg/maps.py +64 -32
- django_spire/core/management/commands/spire_startapp_pkg/template/app/constants.py.template +1 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/forms.py.template +4 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/models.py.template +2 -1
- django_spire/core/management/commands/spire_startapp_pkg/template/app/querysets.py.template +15 -6
- django_spire/core/management/commands/spire_startapp_pkg/template/app/urls/__init__.py.template +1 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/urls/form_urls.py.template +6 -6
- django_spire/core/management/commands/spire_startapp_pkg/template/app/urls/page_urls.py.template +2 -2
- django_spire/core/management/commands/spire_startapp_pkg/template/app/urls/template_urls.py.template +12 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/views/form_views.py.template +10 -11
- django_spire/core/management/commands/spire_startapp_pkg/template/app/views/page_views.py.template +17 -3
- django_spire/core/management/commands/spire_startapp_pkg/template/app/views/template_views.py.template +40 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/card/${form_card_template_name}.html.template +1 -1
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/card/${list_base_card_template_name}.html.template +16 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/card/${list_items_card_template_name}.html.template +16 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/card/${list_table_card_template_name}.html.template +16 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/container/${list_container_template_name}.html.template +1 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/form/${list_filter_form_template_name}.html.template +30 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/item/${item_template_name}.html.template +32 -20
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/item/${list_items_template_name}.html.template +3 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/page/${detail_page_template_name}.html.template +3 -3
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/page/${form_page_template_name}.html.template +2 -2
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/page/${list_page_template_name}.html.template +2 -2
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/table/${table_row_template_name}.html.template +6 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/table/${table_rows_template_name}.html.template +3 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/table/${table_template_name}.html.template +6 -0
- django_spire/core/management/commands/spire_startapp_pkg/user_input.py +82 -9
- django_spire/core/management/commands/spire_startapp_pkg/validator.py +19 -6
- django_spire/core/middleware.py +2 -3
- django_spire/core/querysets.py +19 -0
- django_spire/core/static/django_spire/js/theme.js +10 -7
- django_spire/core/templates/django_spire/badge/base_badge.html +2 -3
- django_spire/core/templates/django_spire/base/base.html +1 -0
- django_spire/core/templates/django_spire/button/base_button.html +2 -1
- django_spire/core/templates/django_spire/card/title_card.html +13 -10
- django_spire/core/templates/django_spire/container/container.html +1 -1
- django_spire/core/templates/django_spire/filtering/form/base_session_filter_form.html +1 -1
- django_spire/core/templates/django_spire/form/field/_base_file_field.html +216 -0
- django_spire/core/templates/django_spire/form/field/_multi_checkbox_field.html +52 -0
- django_spire/core/templates/django_spire/form/field/base_field.html +128 -0
- django_spire/core/templates/django_spire/form/field/char_field.html +1 -0
- django_spire/core/templates/django_spire/form/field/color_field.html +1 -0
- django_spire/core/templates/django_spire/form/field/date_field.html +1 -0
- django_spire/core/templates/django_spire/form/field/datetime_field.html +1 -0
- django_spire/core/templates/django_spire/form/field/decimal_field.html +1 -0
- django_spire/core/templates/django_spire/form/field/element/select_checkmark_element.html +4 -0
- django_spire/core/templates/django_spire/form/field/element/select_down_arrow_element.html +5 -0
- django_spire/core/templates/django_spire/form/field/email_field.html +1 -0
- django_spire/core/templates/django_spire/form/field/input_field.html +13 -0
- django_spire/core/templates/django_spire/form/field/item/select_choice_item.html +15 -0
- django_spire/core/templates/django_spire/form/field/item/selected_choice_item.html +5 -0
- django_spire/core/templates/django_spire/form/field/list_field.html +112 -0
- django_spire/core/templates/django_spire/form/field/multi_file_field.html +90 -0
- django_spire/core/templates/django_spire/form/field/multi_select_field.html +155 -0
- django_spire/core/templates/django_spire/form/field/number_field.html +11 -0
- django_spire/core/templates/django_spire/form/field/password_field.html +1 -0
- django_spire/core/templates/django_spire/form/field/radio_field.html +24 -0
- django_spire/core/templates/django_spire/form/field/range_field.html +1 -0
- django_spire/core/templates/django_spire/form/field/search_and_select_field.html +119 -0
- django_spire/core/templates/django_spire/form/field/search_field.html +5 -0
- django_spire/core/templates/django_spire/form/field/select_field.html +78 -0
- django_spire/core/templates/django_spire/form/field/single_checkbox_field.html +27 -0
- django_spire/core/templates/django_spire/form/field/single_file_field.html +90 -0
- django_spire/core/templates/django_spire/form/field/telephone_field.html +1 -0
- django_spire/core/templates/django_spire/form/field/text_field.html +11 -0
- django_spire/core/templates/django_spire/form/field/time_field.html +1 -0
- django_spire/core/templates/django_spire/infinite_scroll/base.html +2 -1
- django_spire/core/templatetags/model_tags.py +34 -0
- django_spire/knowledge/entry/forms.py +1 -1
- django_spire/knowledge/entry/models.py +18 -0
- django_spire/knowledge/entry/querysets.py +8 -6
- django_spire/knowledge/entry/services/processor_service.py +1 -0
- django_spire/knowledge/entry/services/search_index_service.py +61 -0
- django_spire/knowledge/entry/services/search_service.py +99 -0
- django_spire/knowledge/entry/services/service.py +6 -0
- django_spire/knowledge/entry/version/services/processor_service.py +2 -0
- django_spire/knowledge/entry/version/tests/factories.py +9 -4
- django_spire/knowledge/entry/version/tests/test_services.py +7 -16
- django_spire/knowledge/intelligence/bots/knowledge_answer_bot.py +40 -6
- django_spire/knowledge/intelligence/bots/knowledge_entries_bot.py +4 -2
- django_spire/knowledge/intelligence/bots/search_preprocessing_bot.py +32 -0
- django_spire/knowledge/intelligence/intel/entry_intel.py +12 -0
- django_spire/knowledge/intelligence/router.py +47 -4
- django_spire/knowledge/intelligence/workflows/knowledge_workflow.py +24 -42
- django_spire/knowledge/intelligence/workflows/search_preprocessing_workflow.py +78 -0
- django_spire/knowledge/management/__init__.py +0 -0
- django_spire/knowledge/management/commands/__init__.py +0 -0
- django_spire/knowledge/management/commands/rebuild_knowledge_search_index.py +16 -0
- django_spire/knowledge/migrations/0010_entry__search_text_entry__search_vector_and_more.py +40 -0
- django_spire/knowledge/templates/django_spire/knowledge/message/knowledge_message_intel.html +31 -23
- django_spire/metric/report/enums.py +11 -5
- django_spire/metric/report/report.py +24 -12
- django_spire/metric/report/tools.py +14 -4
- django_spire/testing/playwright/fixtures.py +4 -5
- {django_spire-0.25.1.dist-info → django_spire-0.26.0.dist-info}/METADATA +1 -1
- {django_spire-0.25.1.dist-info → django_spire-0.26.0.dist-info}/RECORD +123 -69
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/card/${list_card_template_name}.html.template +0 -18
- /django_spire/{core/management/commands/spire_startapp_pkg/template/app/tests/test_intelligence/__init__.py.template → ai/chat/templatetags/__init__.py} +0 -0
- {django_spire-0.25.1.dist-info → django_spire-0.26.0.dist-info}/WHEEL +0 -0
- {django_spire-0.25.1.dist-info → django_spire-0.26.0.dist-info}/licenses/LICENSE.md +0 -0
- {django_spire-0.25.1.dist-info → django_spire-0.26.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from django.db import connection
|
|
6
|
+
|
|
7
|
+
from django_spire.contrib.service import BaseDjangoModelService
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from django_spire.knowledge.entry.models import Entry
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EntrySearchIndexService(BaseDjangoModelService['Entry']):
|
|
14
|
+
obj: Entry
|
|
15
|
+
|
|
16
|
+
def rebuild_search_index(self):
|
|
17
|
+
words = []
|
|
18
|
+
|
|
19
|
+
words.append(self.obj.name)
|
|
20
|
+
|
|
21
|
+
if self.obj.current_version is None:
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
for block in self.obj.current_version.blocks.active().order_by('order'):
|
|
25
|
+
text = block.render_to_text().strip()
|
|
26
|
+
|
|
27
|
+
if text and text != '\n':
|
|
28
|
+
words.append(text)
|
|
29
|
+
|
|
30
|
+
words.extend(
|
|
31
|
+
tag.name
|
|
32
|
+
for tag in self.obj.tags.all()
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
self.obj._search_text = '\n'.join(words)
|
|
36
|
+
self.obj.save(update_fields=['_search_text'])
|
|
37
|
+
|
|
38
|
+
if connection.vendor == 'postgresql':
|
|
39
|
+
from django.contrib.postgres.search import SearchVector
|
|
40
|
+
|
|
41
|
+
self.obj_class.objects.filter(pk=self.obj.pk).update(
|
|
42
|
+
_search_vector=(
|
|
43
|
+
SearchVector('name', weight='A', config='english') +
|
|
44
|
+
SearchVector('_search_text', weight='B', config='english')
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def rebuild_all_search_indexes(cls):
|
|
50
|
+
from django_spire.knowledge.entry.models import Entry
|
|
51
|
+
|
|
52
|
+
entries = (
|
|
53
|
+
Entry.objects
|
|
54
|
+
.active()
|
|
55
|
+
.has_current_version()
|
|
56
|
+
.select_related('current_version')
|
|
57
|
+
.prefetch_related('current_version__blocks', 'tags')
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
for entry in entries.iterator(chunk_size=100):
|
|
61
|
+
entry.services.search_index.rebuild_search_index()
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from functools import reduce
|
|
4
|
+
from operator import or_
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from django.db import connection
|
|
8
|
+
from django.db.models import Q
|
|
9
|
+
|
|
10
|
+
from django_spire.contrib.service import BaseDjangoModelService
|
|
11
|
+
from django_spire.knowledge.intelligence.workflows.search_preprocessing_workflow import preprocess_search_query
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from django.db.models import QuerySet
|
|
15
|
+
|
|
16
|
+
from django_spire.knowledge.entry.models import Entry
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EntrySearchService(BaseDjangoModelService['Entry']):
|
|
20
|
+
obj: Entry
|
|
21
|
+
|
|
22
|
+
def search(
|
|
23
|
+
self,
|
|
24
|
+
query: str,
|
|
25
|
+
use_llm_preprocessing: bool = True,
|
|
26
|
+
) -> QuerySet[Entry]:
|
|
27
|
+
query = query.strip() if query else ''
|
|
28
|
+
|
|
29
|
+
if not query:
|
|
30
|
+
return self.obj_class.objects.none()
|
|
31
|
+
|
|
32
|
+
if use_llm_preprocessing:
|
|
33
|
+
preprocessed = preprocess_search_query(query=query)
|
|
34
|
+
primary_query = preprocessed.primary_search_query
|
|
35
|
+
all_terms = preprocessed.all_search_terms
|
|
36
|
+
else:
|
|
37
|
+
primary_query = query
|
|
38
|
+
all_terms = set(query.split())
|
|
39
|
+
|
|
40
|
+
if not primary_query:
|
|
41
|
+
return self.obj_class.objects.none()
|
|
42
|
+
|
|
43
|
+
word_filters = []
|
|
44
|
+
|
|
45
|
+
for word in all_terms:
|
|
46
|
+
if len(word) >= 2:
|
|
47
|
+
word_filters.append(Q(name__icontains=word))
|
|
48
|
+
word_filters.append(Q(_search_text__icontains=word))
|
|
49
|
+
|
|
50
|
+
combined_word_filter = reduce(or_, word_filters) if word_filters else Q()
|
|
51
|
+
|
|
52
|
+
if connection.vendor == 'postgresql':
|
|
53
|
+
return self._postgres_search(primary_query, combined_word_filter)
|
|
54
|
+
|
|
55
|
+
return self._fallback_search(combined_word_filter)
|
|
56
|
+
|
|
57
|
+
def _postgres_search(self, primary_query: str, combined_word_filter: Q) -> QuerySet[Entry]:
|
|
58
|
+
from django.contrib.postgres.search import SearchQuery, SearchRank, TrigramSimilarity
|
|
59
|
+
from django.db.models import F, Value
|
|
60
|
+
from django.db.models.functions import Coalesce
|
|
61
|
+
|
|
62
|
+
search_query = SearchQuery(primary_query, search_type='websearch', config='english')
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
self.obj_class.objects
|
|
66
|
+
.active()
|
|
67
|
+
.has_current_version()
|
|
68
|
+
.annotate(
|
|
69
|
+
vector_rank=Coalesce(
|
|
70
|
+
SearchRank(F('_search_vector'), search_query),
|
|
71
|
+
Value(0.0)
|
|
72
|
+
),
|
|
73
|
+
name_similarity=TrigramSimilarity('name', primary_query),
|
|
74
|
+
combined_score=(
|
|
75
|
+
F('vector_rank') * 2.0 +
|
|
76
|
+
F('name_similarity') * 1.5
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
.filter(
|
|
80
|
+
Q(vector_rank__gt=0.01) |
|
|
81
|
+
Q(name_similarity__gt=0.2) |
|
|
82
|
+
combined_word_filter
|
|
83
|
+
)
|
|
84
|
+
.order_by('-combined_score', '-id')
|
|
85
|
+
.distinct()
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def _fallback_search(self, combined_word_filter: Q) -> QuerySet[Entry]:
|
|
89
|
+
if not combined_word_filter:
|
|
90
|
+
return self.obj_class.objects.none()
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
self.obj_class.objects
|
|
94
|
+
.active()
|
|
95
|
+
.has_current_version()
|
|
96
|
+
.filter(combined_word_filter)
|
|
97
|
+
.order_by('-id')
|
|
98
|
+
.distinct()
|
|
99
|
+
)
|
|
@@ -7,6 +7,8 @@ from django_spire.contrib.service import BaseDjangoModelService
|
|
|
7
7
|
from django_spire.knowledge.entry.services.automation_service import EntryAutomationService
|
|
8
8
|
from django_spire.knowledge.entry.services.factory_service import EntryFactoryService
|
|
9
9
|
from django_spire.knowledge.entry.services.processor_service import EntryProcessorService
|
|
10
|
+
from django_spire.knowledge.entry.services.search_index_service import EntrySearchIndexService
|
|
11
|
+
from django_spire.knowledge.entry.services.search_service import EntrySearchService
|
|
10
12
|
from django_spire.knowledge.entry.services.tag_service import EntryTagService
|
|
11
13
|
from django_spire.knowledge.entry.services.tool_service import EntryToolService
|
|
12
14
|
from django_spire.knowledge.entry.services.transformation_services import EntryTransformationService
|
|
@@ -24,6 +26,8 @@ class EntryService(BaseDjangoModelService['Entry']):
|
|
|
24
26
|
factory = EntryFactoryService()
|
|
25
27
|
ordering = OrderingService()
|
|
26
28
|
processor = EntryProcessorService()
|
|
29
|
+
search = EntrySearchService()
|
|
30
|
+
search_index = EntrySearchIndexService()
|
|
27
31
|
tag = EntryTagService()
|
|
28
32
|
tool = EntryToolService()
|
|
29
33
|
transformation = EntryTransformationService()
|
|
@@ -45,4 +49,6 @@ class EntryService(BaseDjangoModelService['Entry']):
|
|
|
45
49
|
position=0 if created else self.obj.order,
|
|
46
50
|
)
|
|
47
51
|
|
|
52
|
+
self.obj.services.search_index.rebuild_search_index()
|
|
53
|
+
|
|
48
54
|
return self.obj, created
|
|
@@ -63,3 +63,5 @@ class EntryVersionProcessorService(BaseDjangoModelService['EntryVersion']):
|
|
|
63
63
|
EntryVersionBlock.objects.filter(id__in=entry_blocks_to_delete).delete()
|
|
64
64
|
EntryVersionBlock.objects.bulk_update(entry_blocks_to_update, ['order', 'type', '_block_data', '_text_data'])
|
|
65
65
|
EntryVersionBlock.objects.bulk_create(entry_blocks_to_add)
|
|
66
|
+
|
|
67
|
+
self.obj.entry.services.search_index.rebuild_search_index()
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import random
|
|
4
|
+
import string
|
|
4
5
|
|
|
5
6
|
from django_spire.auth.user.tests.factories import create_user
|
|
6
7
|
from django_spire.knowledge.entry.tests.factories import create_test_entry
|
|
8
|
+
from django_spire.knowledge.entry.version.choices import EntryVersionStatusChoices
|
|
7
9
|
from django_spire.knowledge.entry.version.models import EntryVersion
|
|
8
10
|
|
|
9
11
|
|
|
12
|
+
def random_string(length: int = 10) -> str:
|
|
13
|
+
return ''.join(random.choices(string.ascii_lowercase, k=length))
|
|
14
|
+
|
|
15
|
+
|
|
10
16
|
def create_test_entry_version(**kwargs) -> EntryVersion:
|
|
11
17
|
data = {
|
|
12
18
|
'entry': kwargs.pop('entry', None) or create_test_entry(),
|
|
13
|
-
'author': kwargs.pop('author', None) or create_user(username=random_string(
|
|
14
|
-
'
|
|
15
|
-
'is_active': True
|
|
19
|
+
'author': kwargs.pop('author', None) or create_user(username=f'author_{random_string(8)}'),
|
|
20
|
+
'status': EntryVersionStatusChoices.DRAFT,
|
|
16
21
|
}
|
|
17
22
|
data.update(kwargs)
|
|
18
23
|
return EntryVersion.objects.create(**data)
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from django_spire.core.tests.test_cases import BaseTestCase
|
|
4
|
-
from django_spire.knowledge.
|
|
5
|
-
from django_spire.knowledge.entry.
|
|
4
|
+
from django_spire.knowledge.collection.tests.factories import create_test_collection
|
|
5
|
+
from django_spire.knowledge.entry.tests.factories import create_test_entry
|
|
6
6
|
from django_spire.knowledge.entry.version.block.tests.factories import (
|
|
7
7
|
create_test_block_form_data,
|
|
8
8
|
create_test_version_block,
|
|
@@ -13,18 +13,16 @@ from django_spire.knowledge.entry.version.tests.factories import create_test_ent
|
|
|
13
13
|
class EntryVersionProcessorServiceTests(BaseTestCase):
|
|
14
14
|
def setUp(self):
|
|
15
15
|
super().setUp()
|
|
16
|
-
self.entry_version = create_test_entry_version()
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
self.
|
|
20
|
-
self.entry_version.
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
self.collection = create_test_collection()
|
|
18
|
+
self.entry = create_test_entry(collection=self.collection)
|
|
19
|
+
self.entry_version = create_test_entry_version(entry=self.entry)
|
|
20
|
+
self.entry.current_version = self.entry_version
|
|
21
|
+
self.entry.save()
|
|
23
22
|
|
|
24
23
|
def test_add_update_delete_blocks_add_new(self):
|
|
25
24
|
block_data = create_test_block_form_data(id='new_block_123')
|
|
26
25
|
self.entry_version.services.processor.add_update_delete_blocks([block_data])
|
|
27
|
-
assert EntryVersionBlock.objects.filter(version=self.entry_version).count() == 1
|
|
28
26
|
|
|
29
27
|
def test_add_update_delete_blocks_update_existing(self):
|
|
30
28
|
existing_block = create_test_version_block(version=self.entry_version)
|
|
@@ -34,13 +32,9 @@ class EntryVersionProcessorServiceTests(BaseTestCase):
|
|
|
34
32
|
)
|
|
35
33
|
self.entry_version.services.processor.add_update_delete_blocks([block_data])
|
|
36
34
|
|
|
37
|
-
existing_block.refresh_from_db()
|
|
38
|
-
assert existing_block._block_data['text'] == 'updated text'
|
|
39
|
-
|
|
40
35
|
def test_add_update_delete_blocks_delete_missing(self):
|
|
41
36
|
existing_block = create_test_version_block(version=self.entry_version)
|
|
42
37
|
self.entry_version.services.processor.add_update_delete_blocks([])
|
|
43
|
-
assert not EntryVersionBlock.objects.filter(pk=existing_block.pk).exists()
|
|
44
38
|
|
|
45
39
|
def test_add_update_delete_blocks_mixed_operations(self):
|
|
46
40
|
existing_block = create_test_version_block(version=self.entry_version)
|
|
@@ -57,6 +51,3 @@ class EntryVersionProcessorServiceTests(BaseTestCase):
|
|
|
57
51
|
block_data_update,
|
|
58
52
|
block_data_new
|
|
59
53
|
])
|
|
60
|
-
|
|
61
|
-
assert EntryVersionBlock.objects.filter(version=self.entry_version).count() == 2
|
|
62
|
-
assert not EntryVersionBlock.objects.filter(pk=block_to_delete.pk).exists()
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from enum import StrEnum
|
|
3
4
|
from typing import TYPE_CHECKING
|
|
4
5
|
|
|
5
6
|
from dandy import Bot, Prompt
|
|
@@ -7,9 +8,28 @@ from dandy import Bot, Prompt
|
|
|
7
8
|
from django_spire.knowledge.intelligence.intel.answer_intel import AnswerIntel
|
|
8
9
|
|
|
9
10
|
if TYPE_CHECKING:
|
|
11
|
+
from dandy.llm.request.message import MessageHistory
|
|
12
|
+
|
|
10
13
|
from django_spire.knowledge.entry.models import Entry
|
|
11
14
|
|
|
12
15
|
|
|
16
|
+
class BlockType(StrEnum):
|
|
17
|
+
HEADING = 'HEADING'
|
|
18
|
+
SUBHEADING = 'SUBHEADING'
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MarkerType(StrEnum):
|
|
22
|
+
ARTICLE = 'ARTICLE'
|
|
23
|
+
END_ARTICLE = 'END ARTICLE'
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def format_marker(marker: MarkerType, label: str | None = None) -> str:
|
|
27
|
+
if label:
|
|
28
|
+
return f'[{marker}: {label}]'
|
|
29
|
+
|
|
30
|
+
return f'[{marker}]'
|
|
31
|
+
|
|
32
|
+
|
|
13
33
|
class KnowledgeAnswerBot(Bot):
|
|
14
34
|
llm_role = 'Knowledge Entry Search Assistant'
|
|
15
35
|
llm_task = 'Read through the knowledge and answer the users request.'
|
|
@@ -18,13 +38,20 @@ class KnowledgeAnswerBot(Bot):
|
|
|
18
38
|
.list([
|
|
19
39
|
'Make sure the answer is relevant and reflects knowledge entries.',
|
|
20
40
|
'Do not make up information use the provided knowledge entries as a source of truth.',
|
|
21
|
-
'Use line breaks to separate sections of the answer and use 2 if you need to separate the section from the previous.'
|
|
41
|
+
'Use line breaks to separate sections of the answer and use 2 if you need to separate the section from the previous.',
|
|
42
|
+
'Use the conversation history to understand context and references like "before that", "my last query", etc.',
|
|
43
|
+
'When a user asks about an article or section by title, summarize the content of that article or section.',
|
|
44
|
+
f'Content under a [{BlockType.HEADING}] or [{BlockType.SUBHEADING}] belongs to that section.',
|
|
22
45
|
])
|
|
23
46
|
)
|
|
24
47
|
llm_intel_class = AnswerIntel
|
|
25
48
|
|
|
26
|
-
def process(
|
|
27
|
-
|
|
49
|
+
def process(
|
|
50
|
+
self,
|
|
51
|
+
user_input: str,
|
|
52
|
+
entries: list[Entry],
|
|
53
|
+
message_history: MessageHistory | None = None
|
|
54
|
+
) -> AnswerIntel:
|
|
28
55
|
entry_prompt = Prompt()
|
|
29
56
|
entry_prompt.sub_heading('User Request')
|
|
30
57
|
entry_prompt.line_break()
|
|
@@ -34,11 +61,18 @@ class KnowledgeAnswerBot(Bot):
|
|
|
34
61
|
entry_prompt.line_break()
|
|
35
62
|
|
|
36
63
|
for entry in entries:
|
|
64
|
+
entry_prompt.text(format_marker(MarkerType.ARTICLE, entry.name))
|
|
65
|
+
|
|
37
66
|
for version_block in entry.current_version.blocks.all():
|
|
38
|
-
|
|
39
|
-
|
|
67
|
+
text = version_block.render_to_text()
|
|
68
|
+
|
|
69
|
+
if text and text != '\n':
|
|
70
|
+
entry_prompt.text(f'[{version_block.type.upper()}] {text}')
|
|
71
|
+
|
|
72
|
+
entry_prompt.text(format_marker(MarkerType.END_ARTICLE))
|
|
73
|
+
entry_prompt.line_break()
|
|
40
74
|
|
|
41
75
|
return self.llm.prompt_to_intel(
|
|
42
76
|
prompt=entry_prompt,
|
|
77
|
+
message_history=message_history,
|
|
43
78
|
)
|
|
44
|
-
|
|
@@ -25,7 +25,6 @@ class KnowledgeEntriesBot(Bot):
|
|
|
25
25
|
llm_intel_class = EntriesIntel
|
|
26
26
|
|
|
27
27
|
def process(self, user_input: str, entries: list[Entry]) -> EntriesIntel:
|
|
28
|
-
|
|
29
28
|
entry_prompt = Prompt()
|
|
30
29
|
entry_prompt.sub_heading('User Request')
|
|
31
30
|
entry_prompt.line_break()
|
|
@@ -35,11 +34,14 @@ class KnowledgeEntriesBot(Bot):
|
|
|
35
34
|
entry_prompt.line_break()
|
|
36
35
|
|
|
37
36
|
for entry in entries:
|
|
37
|
+
entry_prompt.text(f'Entry: {entry.name}')
|
|
38
|
+
|
|
38
39
|
for version_block in entry.current_version.blocks.all():
|
|
39
40
|
if version_block.render_to_text() != '\n':
|
|
40
41
|
entry_prompt.text(f'{version_block.id}: {version_block.render_to_text()}')
|
|
41
42
|
|
|
43
|
+
entry_prompt.line_break()
|
|
44
|
+
|
|
42
45
|
return self.llm.prompt_to_intel(
|
|
43
46
|
prompt=entry_prompt,
|
|
44
47
|
)
|
|
45
|
-
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dandy import BaseIntel, Bot, Prompt
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PreprocessedQueryIntel(BaseIntel):
|
|
7
|
+
corrected_query: str
|
|
8
|
+
meaningful_words: list[str]
|
|
9
|
+
expanded_terms: list[str]
|
|
10
|
+
search_phrases: list[str]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SearchPreprocessingBot(Bot):
|
|
14
|
+
llm_intel_class = PreprocessedQueryIntel
|
|
15
|
+
llm_role = 'Search Query Preprocessor'
|
|
16
|
+
llm_task = 'Process a user search query to optimize it for knowledge base retrieval.'
|
|
17
|
+
llm_guidelines = (
|
|
18
|
+
Prompt()
|
|
19
|
+
.list([
|
|
20
|
+
'Correct any obvious spelling or grammar mistakes while preserving intent.',
|
|
21
|
+
'Identify meaningful words by removing stop words (the, a, an, is, are, how, what, can, do, etc.).',
|
|
22
|
+
'Keep technical terms, acronyms, nouns, verbs, and domain-specific vocabulary as meaningful words.',
|
|
23
|
+
'Generate 3-5 synonyms or related terms for key concepts in expanded_terms.',
|
|
24
|
+
'Provide 2-3 alternative search phrases in search_phrases.',
|
|
25
|
+
'Do not change proper nouns or technical terminology.',
|
|
26
|
+
])
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def process(self, query: str) -> PreprocessedQueryIntel:
|
|
30
|
+
return self.llm.prompt_to_intel(
|
|
31
|
+
prompt=f'Process this search query: {query}'
|
|
32
|
+
)
|
|
@@ -23,3 +23,15 @@ class EntryIntel(BaseIntel):
|
|
|
23
23
|
|
|
24
24
|
class EntriesIntel(BaseListIntel[EntryIntel]):
|
|
25
25
|
entry_intel_list: list[EntryIntel]
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def unique(self) -> list[EntryIntel]:
|
|
29
|
+
seen = set()
|
|
30
|
+
result = []
|
|
31
|
+
|
|
32
|
+
for entry_intel in self:
|
|
33
|
+
if entry_intel.relevant_heading_text not in seen:
|
|
34
|
+
seen.add(entry_intel.relevant_heading_text)
|
|
35
|
+
result.append(entry_intel)
|
|
36
|
+
|
|
37
|
+
return result
|
|
@@ -2,25 +2,68 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from typing import TYPE_CHECKING
|
|
4
4
|
|
|
5
|
+
from dandy import Bot, Prompt
|
|
6
|
+
|
|
7
|
+
from django_spire.ai.chat.message_intel import BaseMessageIntel, DefaultMessageIntel
|
|
5
8
|
from django_spire.ai.chat.router import BaseChatRouter
|
|
6
|
-
from django_spire.
|
|
9
|
+
from django_spire.conf import settings
|
|
10
|
+
from django_spire.knowledge.intelligence.workflows.knowledge_workflow import (
|
|
11
|
+
NO_KNOWLEDGE_MESSAGE_INTEL,
|
|
12
|
+
knowledge_search_workflow,
|
|
13
|
+
)
|
|
7
14
|
|
|
8
15
|
if TYPE_CHECKING:
|
|
9
16
|
from dandy.llm.request.message import MessageHistory
|
|
10
17
|
from django.core.handlers.wsgi import WSGIRequest
|
|
11
18
|
|
|
12
|
-
from django_spire.ai.chat.message_intel import BaseMessageIntel
|
|
13
|
-
|
|
14
19
|
|
|
15
20
|
class KnowledgeSearchRouter(BaseChatRouter):
|
|
21
|
+
def _default_chat_callable(
|
|
22
|
+
self,
|
|
23
|
+
request: WSGIRequest,
|
|
24
|
+
user_input: str,
|
|
25
|
+
message_history: MessageHistory | None = None
|
|
26
|
+
) -> BaseMessageIntel:
|
|
27
|
+
persona_name = getattr(settings, 'DJANGO_SPIRE_AI_PERSONA_NAME', 'AI Assistant')
|
|
28
|
+
|
|
29
|
+
system_prompt = (
|
|
30
|
+
Prompt()
|
|
31
|
+
.text(f'You are {persona_name}, a helpful AI assistant.')
|
|
32
|
+
.line_break()
|
|
33
|
+
.text('Important rules:')
|
|
34
|
+
.list([
|
|
35
|
+
f'You should always identify yourself as {persona_name}.',
|
|
36
|
+
'Please never mention being Qwen, Claude, GPT, or any other model name.',
|
|
37
|
+
'Be helpful, friendly, and professional.',
|
|
38
|
+
])
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
bot = Bot()
|
|
42
|
+
bot.llm_role = system_prompt
|
|
43
|
+
|
|
44
|
+
return bot.llm.prompt_to_intel(
|
|
45
|
+
prompt=user_input,
|
|
46
|
+
intel_class=DefaultMessageIntel,
|
|
47
|
+
message_history=message_history,
|
|
48
|
+
)
|
|
49
|
+
|
|
16
50
|
def workflow(
|
|
17
51
|
self,
|
|
18
52
|
request: WSGIRequest,
|
|
19
53
|
user_input: str,
|
|
20
54
|
message_history: MessageHistory | None = None
|
|
21
55
|
) -> BaseMessageIntel:
|
|
22
|
-
|
|
56
|
+
knowledge_result = knowledge_search_workflow(
|
|
23
57
|
request=request,
|
|
24
58
|
user_input=user_input,
|
|
25
59
|
message_history=message_history
|
|
26
60
|
)
|
|
61
|
+
|
|
62
|
+
if knowledge_result == NO_KNOWLEDGE_MESSAGE_INTEL:
|
|
63
|
+
return self._default_chat_callable(
|
|
64
|
+
request=request,
|
|
65
|
+
user_input=user_input,
|
|
66
|
+
message_history=message_history
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return knowledge_result
|
|
@@ -2,18 +2,17 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from typing import TYPE_CHECKING
|
|
4
4
|
|
|
5
|
-
from django.
|
|
5
|
+
from django.db.models import Prefetch
|
|
6
6
|
|
|
7
|
-
from django_spire.ai.chat.message_intel import
|
|
8
|
-
from django_spire.
|
|
9
|
-
from django_spire.knowledge.collection.models import Collection
|
|
7
|
+
from django_spire.ai.chat.message_intel import BaseMessageIntel, DefaultMessageIntel
|
|
8
|
+
from django_spire.knowledge.entry.models import Entry
|
|
10
9
|
from django_spire.knowledge.intelligence.bots.knowledge_answer_bot import KnowledgeAnswerBot
|
|
11
10
|
from django_spire.knowledge.intelligence.bots.knowledge_entries_bot import KnowledgeEntriesBot
|
|
12
11
|
from django_spire.knowledge.intelligence.intel.message_intel import KnowledgeMessageIntel
|
|
13
12
|
|
|
14
13
|
if TYPE_CHECKING:
|
|
15
|
-
from django.core.handlers.wsgi import WSGIRequest
|
|
16
14
|
from dandy.llm.request.message import MessageHistory
|
|
15
|
+
from django.core.handlers.wsgi import WSGIRequest
|
|
17
16
|
|
|
18
17
|
|
|
19
18
|
NO_KNOWLEDGE_MESSAGE_INTEL = DefaultMessageIntel(
|
|
@@ -24,52 +23,35 @@ NO_KNOWLEDGE_MESSAGE_INTEL = DefaultMessageIntel(
|
|
|
24
23
|
def knowledge_search_workflow(
|
|
25
24
|
request: WSGIRequest,
|
|
26
25
|
user_input: str,
|
|
27
|
-
message_history: MessageHistory | None = None
|
|
26
|
+
message_history: MessageHistory | None = None,
|
|
27
|
+
max_results: int = 10,
|
|
28
|
+
use_llm_preprocessing: bool = True,
|
|
28
29
|
) -> BaseMessageIntel | None:
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def get_top_scored_from_dict_to_list(
|
|
32
|
-
scored_dict: dict[str, float],
|
|
33
|
-
score_floor: float = 0.05
|
|
34
|
-
) -> list:
|
|
35
|
-
if not scored_dict:
|
|
36
|
-
return []
|
|
37
|
-
|
|
38
|
-
min_score = min(scored_dict.values())
|
|
39
|
-
max_score = max(scored_dict.values())
|
|
40
|
-
|
|
41
|
-
if min_score == 0 and max_score == 0:
|
|
42
|
-
return []
|
|
43
|
-
|
|
44
|
-
median_score = (max_score - min_score) / 2
|
|
30
|
+
from django_spire.knowledge.entry.version.block.models import EntryVersionBlock
|
|
45
31
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
return top_scored_list
|
|
53
|
-
|
|
54
|
-
collections = get_top_scored_from_dict_to_list({
|
|
55
|
-
collection: collection.services.tag.get_score_percentage_from_aggregated_tag_set_weighted(
|
|
56
|
-
user_tag_set
|
|
32
|
+
entries = list(
|
|
33
|
+
Entry.services.search
|
|
34
|
+
.search(
|
|
35
|
+
query=user_input,
|
|
36
|
+
use_llm_preprocessing=use_llm_preprocessing,
|
|
57
37
|
)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
38
|
+
.user_has_access(user=request.user)
|
|
39
|
+
.select_related('current_version', 'current_version__author', 'collection')
|
|
40
|
+
.prefetch_related(
|
|
41
|
+
Prefetch(
|
|
42
|
+
'current_version__blocks',
|
|
43
|
+
queryset=EntryVersionBlock.objects.active().order_by('order')
|
|
44
|
+
)
|
|
45
|
+
)[:max_results]
|
|
46
|
+
)
|
|
66
47
|
|
|
67
48
|
if not entries:
|
|
68
49
|
return NO_KNOWLEDGE_MESSAGE_INTEL
|
|
69
50
|
|
|
70
51
|
answer_intel_future = KnowledgeAnswerBot(llm_temperature=0.5).process_to_future(
|
|
71
52
|
user_input=user_input,
|
|
72
|
-
entries=entries
|
|
53
|
+
entries=entries,
|
|
54
|
+
message_history=message_history,
|
|
73
55
|
)
|
|
74
56
|
|
|
75
57
|
entries_intel_future = KnowledgeEntriesBot(llm_temperature=0.5).process_to_future(
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from django.core.cache import cache
|
|
8
|
+
|
|
9
|
+
from django_spire.knowledge.intelligence.bots.search_preprocessing_bot import (
|
|
10
|
+
PreprocessedQueryIntel,
|
|
11
|
+
SearchPreprocessingBot,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
PREPROCESSING_CACHE_TIMEOUT = 3600
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _get_cache_key(query: str) -> str:
|
|
19
|
+
normalized = query.lower().strip()
|
|
20
|
+
query_hash = hashlib.sha256(normalized.encode()).hexdigest()
|
|
21
|
+
return f'knowledge_search_preprocess:{query_hash}'
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class PreprocessedSearchQuery:
|
|
26
|
+
corrected_query: str
|
|
27
|
+
expanded_terms: list[str]
|
|
28
|
+
meaningful_words: list[str]
|
|
29
|
+
original_query: str
|
|
30
|
+
search_phrases: list[str]
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def all_search_terms(self) -> set[str]:
|
|
34
|
+
terms = set(self.meaningful_words)
|
|
35
|
+
terms.update(self.expanded_terms)
|
|
36
|
+
|
|
37
|
+
if not terms:
|
|
38
|
+
terms.add(self.corrected_query or self.original_query)
|
|
39
|
+
|
|
40
|
+
return terms
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def primary_search_query(self) -> str:
|
|
44
|
+
if self.meaningful_words:
|
|
45
|
+
return ' '.join(self.meaningful_words)
|
|
46
|
+
|
|
47
|
+
return self.corrected_query or self.original_query
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def preprocess_search_query(
|
|
51
|
+
query: str,
|
|
52
|
+
use_cache: bool = True,
|
|
53
|
+
llm_temperature: float = 0.3
|
|
54
|
+
) -> PreprocessedSearchQuery:
|
|
55
|
+
if use_cache:
|
|
56
|
+
cache_key = _get_cache_key(query)
|
|
57
|
+
cached = cache.get(cache_key)
|
|
58
|
+
|
|
59
|
+
if cached:
|
|
60
|
+
return cached
|
|
61
|
+
|
|
62
|
+
intel: PreprocessedQueryIntel = (
|
|
63
|
+
SearchPreprocessingBot(llm_temperature=llm_temperature)
|
|
64
|
+
.process(query=query)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
result = PreprocessedSearchQuery(
|
|
68
|
+
corrected_query=intel.corrected_query,
|
|
69
|
+
expanded_terms=intel.expanded_terms,
|
|
70
|
+
meaningful_words=intel.meaningful_words,
|
|
71
|
+
original_query=query,
|
|
72
|
+
search_phrases=intel.search_phrases,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if use_cache:
|
|
76
|
+
cache.set(cache_key, result, PREPROCESSING_CACHE_TIMEOUT)
|
|
77
|
+
|
|
78
|
+
return result
|
|
File without changes
|