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.
Files changed (124) hide show
  1. django_spire/ai/chat/router.py +10 -20
  2. django_spire/ai/chat/templates/django_spire/ai/chat/dropdown/ellipsis_dropdown.html +5 -3
  3. django_spire/ai/chat/templates/django_spire/ai/chat/message/default_message.html +6 -2
  4. django_spire/ai/chat/templatetags/spire_ai_chat_tags.py +19 -0
  5. django_spire/ai/chat/tests/test_router/test_spire_chat_router.py +20 -88
  6. django_spire/auth/templates/django_spire/auth/element/android_and_chrome_app_install_element.html +33 -0
  7. django_spire/auth/templates/django_spire/auth/element/ios_app_install_element.html +48 -0
  8. django_spire/auth/templates/django_spire/auth/page/auth_page.html +2 -1
  9. django_spire/auth/templates/django_spire/auth/page/login_page.html +2 -0
  10. django_spire/comment/mixins.py +3 -3
  11. django_spire/comment/templates/django_spire/comment/card/comment_list_card.html +8 -5
  12. django_spire/comment/templates/django_spire/comment/form/comment_form.html +3 -3
  13. django_spire/comment/templates/django_spire/comment/form/content/comment_form_content.html +1 -0
  14. django_spire/comment/templates/django_spire/comment/item/comment_item_ellipsis.html +12 -8
  15. django_spire/comment/views.py +8 -8
  16. django_spire/consts.py +1 -1
  17. django_spire/contrib/form/utils.py +3 -3
  18. django_spire/contrib/progress/session.py +1 -1
  19. django_spire/contrib/queryset/filter_tools.py +56 -14
  20. django_spire/contrib/queryset/mixins.py +24 -3
  21. django_spire/contrib/service/django_model_service.py +5 -6
  22. django_spire/core/management/commands/spire_startapp.py +42 -25
  23. django_spire/core/management/commands/spire_startapp_pkg/exceptions.py +5 -0
  24. django_spire/core/management/commands/spire_startapp_pkg/maps.py +64 -32
  25. django_spire/core/management/commands/spire_startapp_pkg/template/app/constants.py.template +1 -0
  26. django_spire/core/management/commands/spire_startapp_pkg/template/app/forms.py.template +4 -0
  27. django_spire/core/management/commands/spire_startapp_pkg/template/app/models.py.template +2 -1
  28. django_spire/core/management/commands/spire_startapp_pkg/template/app/querysets.py.template +15 -6
  29. django_spire/core/management/commands/spire_startapp_pkg/template/app/urls/__init__.py.template +1 -0
  30. django_spire/core/management/commands/spire_startapp_pkg/template/app/urls/form_urls.py.template +6 -6
  31. django_spire/core/management/commands/spire_startapp_pkg/template/app/urls/page_urls.py.template +2 -2
  32. django_spire/core/management/commands/spire_startapp_pkg/template/app/urls/template_urls.py.template +12 -0
  33. django_spire/core/management/commands/spire_startapp_pkg/template/app/views/form_views.py.template +10 -11
  34. django_spire/core/management/commands/spire_startapp_pkg/template/app/views/page_views.py.template +17 -3
  35. django_spire/core/management/commands/spire_startapp_pkg/template/app/views/template_views.py.template +40 -0
  36. django_spire/core/management/commands/spire_startapp_pkg/template/templates/card/${form_card_template_name}.html.template +1 -1
  37. django_spire/core/management/commands/spire_startapp_pkg/template/templates/card/${list_base_card_template_name}.html.template +16 -0
  38. django_spire/core/management/commands/spire_startapp_pkg/template/templates/card/${list_items_card_template_name}.html.template +16 -0
  39. django_spire/core/management/commands/spire_startapp_pkg/template/templates/card/${list_table_card_template_name}.html.template +16 -0
  40. django_spire/core/management/commands/spire_startapp_pkg/template/templates/container/${list_container_template_name}.html.template +1 -0
  41. django_spire/core/management/commands/spire_startapp_pkg/template/templates/form/${list_filter_form_template_name}.html.template +30 -0
  42. django_spire/core/management/commands/spire_startapp_pkg/template/templates/item/${item_template_name}.html.template +32 -20
  43. django_spire/core/management/commands/spire_startapp_pkg/template/templates/item/${list_items_template_name}.html.template +3 -0
  44. django_spire/core/management/commands/spire_startapp_pkg/template/templates/page/${detail_page_template_name}.html.template +3 -3
  45. django_spire/core/management/commands/spire_startapp_pkg/template/templates/page/${form_page_template_name}.html.template +2 -2
  46. django_spire/core/management/commands/spire_startapp_pkg/template/templates/page/${list_page_template_name}.html.template +2 -2
  47. django_spire/core/management/commands/spire_startapp_pkg/template/templates/table/${table_row_template_name}.html.template +6 -0
  48. django_spire/core/management/commands/spire_startapp_pkg/template/templates/table/${table_rows_template_name}.html.template +3 -0
  49. django_spire/core/management/commands/spire_startapp_pkg/template/templates/table/${table_template_name}.html.template +6 -0
  50. django_spire/core/management/commands/spire_startapp_pkg/user_input.py +82 -9
  51. django_spire/core/management/commands/spire_startapp_pkg/validator.py +19 -6
  52. django_spire/core/middleware.py +2 -3
  53. django_spire/core/querysets.py +19 -0
  54. django_spire/core/static/django_spire/js/theme.js +10 -7
  55. django_spire/core/templates/django_spire/badge/base_badge.html +2 -3
  56. django_spire/core/templates/django_spire/base/base.html +1 -0
  57. django_spire/core/templates/django_spire/button/base_button.html +2 -1
  58. django_spire/core/templates/django_spire/card/title_card.html +13 -10
  59. django_spire/core/templates/django_spire/container/container.html +1 -1
  60. django_spire/core/templates/django_spire/filtering/form/base_session_filter_form.html +1 -1
  61. django_spire/core/templates/django_spire/form/field/_base_file_field.html +216 -0
  62. django_spire/core/templates/django_spire/form/field/_multi_checkbox_field.html +52 -0
  63. django_spire/core/templates/django_spire/form/field/base_field.html +128 -0
  64. django_spire/core/templates/django_spire/form/field/char_field.html +1 -0
  65. django_spire/core/templates/django_spire/form/field/color_field.html +1 -0
  66. django_spire/core/templates/django_spire/form/field/date_field.html +1 -0
  67. django_spire/core/templates/django_spire/form/field/datetime_field.html +1 -0
  68. django_spire/core/templates/django_spire/form/field/decimal_field.html +1 -0
  69. django_spire/core/templates/django_spire/form/field/element/select_checkmark_element.html +4 -0
  70. django_spire/core/templates/django_spire/form/field/element/select_down_arrow_element.html +5 -0
  71. django_spire/core/templates/django_spire/form/field/email_field.html +1 -0
  72. django_spire/core/templates/django_spire/form/field/input_field.html +13 -0
  73. django_spire/core/templates/django_spire/form/field/item/select_choice_item.html +15 -0
  74. django_spire/core/templates/django_spire/form/field/item/selected_choice_item.html +5 -0
  75. django_spire/core/templates/django_spire/form/field/list_field.html +112 -0
  76. django_spire/core/templates/django_spire/form/field/multi_file_field.html +90 -0
  77. django_spire/core/templates/django_spire/form/field/multi_select_field.html +155 -0
  78. django_spire/core/templates/django_spire/form/field/number_field.html +11 -0
  79. django_spire/core/templates/django_spire/form/field/password_field.html +1 -0
  80. django_spire/core/templates/django_spire/form/field/radio_field.html +24 -0
  81. django_spire/core/templates/django_spire/form/field/range_field.html +1 -0
  82. django_spire/core/templates/django_spire/form/field/search_and_select_field.html +119 -0
  83. django_spire/core/templates/django_spire/form/field/search_field.html +5 -0
  84. django_spire/core/templates/django_spire/form/field/select_field.html +78 -0
  85. django_spire/core/templates/django_spire/form/field/single_checkbox_field.html +27 -0
  86. django_spire/core/templates/django_spire/form/field/single_file_field.html +90 -0
  87. django_spire/core/templates/django_spire/form/field/telephone_field.html +1 -0
  88. django_spire/core/templates/django_spire/form/field/text_field.html +11 -0
  89. django_spire/core/templates/django_spire/form/field/time_field.html +1 -0
  90. django_spire/core/templates/django_spire/infinite_scroll/base.html +2 -1
  91. django_spire/core/templatetags/model_tags.py +34 -0
  92. django_spire/knowledge/entry/forms.py +1 -1
  93. django_spire/knowledge/entry/models.py +18 -0
  94. django_spire/knowledge/entry/querysets.py +8 -6
  95. django_spire/knowledge/entry/services/processor_service.py +1 -0
  96. django_spire/knowledge/entry/services/search_index_service.py +61 -0
  97. django_spire/knowledge/entry/services/search_service.py +99 -0
  98. django_spire/knowledge/entry/services/service.py +6 -0
  99. django_spire/knowledge/entry/version/services/processor_service.py +2 -0
  100. django_spire/knowledge/entry/version/tests/factories.py +9 -4
  101. django_spire/knowledge/entry/version/tests/test_services.py +7 -16
  102. django_spire/knowledge/intelligence/bots/knowledge_answer_bot.py +40 -6
  103. django_spire/knowledge/intelligence/bots/knowledge_entries_bot.py +4 -2
  104. django_spire/knowledge/intelligence/bots/search_preprocessing_bot.py +32 -0
  105. django_spire/knowledge/intelligence/intel/entry_intel.py +12 -0
  106. django_spire/knowledge/intelligence/router.py +47 -4
  107. django_spire/knowledge/intelligence/workflows/knowledge_workflow.py +24 -42
  108. django_spire/knowledge/intelligence/workflows/search_preprocessing_workflow.py +78 -0
  109. django_spire/knowledge/management/__init__.py +0 -0
  110. django_spire/knowledge/management/commands/__init__.py +0 -0
  111. django_spire/knowledge/management/commands/rebuild_knowledge_search_index.py +16 -0
  112. django_spire/knowledge/migrations/0010_entry__search_text_entry__search_vector_and_more.py +40 -0
  113. django_spire/knowledge/templates/django_spire/knowledge/message/knowledge_message_intel.html +31 -23
  114. django_spire/metric/report/enums.py +11 -5
  115. django_spire/metric/report/report.py +24 -12
  116. django_spire/metric/report/tools.py +14 -4
  117. django_spire/testing/playwright/fixtures.py +4 -5
  118. {django_spire-0.25.1.dist-info → django_spire-0.26.0.dist-info}/METADATA +1 -1
  119. {django_spire-0.25.1.dist-info → django_spire-0.26.0.dist-info}/RECORD +123 -69
  120. django_spire/core/management/commands/spire_startapp_pkg/template/templates/card/${list_card_template_name}.html.template +0 -18
  121. /django_spire/{core/management/commands/spire_startapp_pkg/template/app/tests/test_intelligence/__init__.py.template → ai/chat/templatetags/__init__.py} +0 -0
  122. {django_spire-0.25.1.dist-info → django_spire-0.26.0.dist-info}/WHEEL +0 -0
  123. {django_spire-0.25.1.dist-info → django_spire-0.26.0.dist-info}/licenses/LICENSE.md +0 -0
  124. {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
- from keyring.testing.util import random_string
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(k=10)),
14
- 'is_deleted': False,
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.entry.version.choices import EntryVersionStatusChoices
5
- from django_spire.knowledge.entry.version.block.models import EntryVersionBlock
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
- def test_publish(self):
19
- self.entry_version.services.processor.publish()
20
- self.entry_version.refresh_from_db()
21
- assert self.entry_version.status == EntryVersionStatusChoices.PUBLISHED
22
- assert self.entry_version.published_datetime is not None
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(self, user_input: str, entries: list[Entry]) -> AnswerIntel:
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
- if version_block.render_to_text() != '\n':
39
- entry_prompt.text(f'{version_block.render_to_text()}')
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.knowledge.intelligence.workflows.knowledge_workflow import knowledge_search_workflow
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
- return knowledge_search_workflow(
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.core.handlers.wsgi import WSGIRequest
5
+ from django.db.models import Prefetch
6
6
 
7
- from django_spire.ai.chat.message_intel import DefaultMessageIntel, BaseMessageIntel
8
- from django_spire.core.tag.intelligence.tag_set_bot import TagSetBot
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
- user_tag_set = TagSetBot().process(user_input)
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
- top_scored_list = []
47
-
48
- for key, value in scored_dict.items():
49
- if value >= score_floor and value >= median_score:
50
- top_scored_list.append(key)
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
- for collection in Collection.objects.all().annotate_entry_count()
59
- })
60
-
61
- entries = get_top_scored_from_dict_to_list({
62
- entry: entry.services.tag.get_score_percentage_from_tag_set_weighted(user_tag_set)
63
- for collection in collections
64
- for entry in collection.entries.all()
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