django-spire 0.25.1__py3-none-any.whl → 0.25.2__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/__init__.py +0 -0
- 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/consts.py +1 -1
- django_spire/contrib/progress/session.py +1 -1
- django_spire/core/middleware.py +2 -3
- django_spire/core/static/django_spire/js/theme.js +10 -7
- 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 -2
- django_spire/testing/playwright/fixtures.py +4 -5
- {django_spire-0.25.1.dist-info → django_spire-0.25.2.dist-info}/METADATA +1 -1
- {django_spire-0.25.1.dist-info → django_spire-0.25.2.dist-info}/RECORD +41 -31
- {django_spire-0.25.1.dist-info → django_spire-0.25.2.dist-info}/WHEEL +0 -0
- {django_spire-0.25.1.dist-info → django_spire-0.25.2.dist-info}/licenses/LICENSE.md +0 -0
- {django_spire-0.25.1.dist-info → django_spire-0.25.2.dist-info}/top_level.txt +0 -0
|
@@ -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
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from django.core.management.base import BaseCommand
|
|
4
|
+
|
|
5
|
+
from django_spire.knowledge.entry.models import Entry
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Command(BaseCommand):
|
|
9
|
+
help = 'Rebuilds the search index for all knowledge base entries.'
|
|
10
|
+
|
|
11
|
+
def handle(self, *args, **options):
|
|
12
|
+
self.stdout.write('Rebuilding search indexes...')
|
|
13
|
+
|
|
14
|
+
Entry.services.search_index.rebuild_all_search_indexes()
|
|
15
|
+
|
|
16
|
+
self.stdout.write(self.style.SUCCESS('Search indexes rebuilt successfully.'))
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Generated by Django 5.1.15 on 2026-01-23 16:22
|
|
2
|
+
|
|
3
|
+
import django.contrib.postgres.indexes
|
|
4
|
+
import django.contrib.postgres.search
|
|
5
|
+
from django.contrib.postgres.operations import TrigramExtension
|
|
6
|
+
from django.db import migrations, models
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Migration(migrations.Migration):
|
|
10
|
+
|
|
11
|
+
dependencies = [
|
|
12
|
+
('django_spire_core', '0001_initial'),
|
|
13
|
+
('django_spire_knowledge', '0009_alter_collection_tags_alter_entry_tags'),
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
operations = [
|
|
17
|
+
TrigramExtension(),
|
|
18
|
+
migrations.AddField(
|
|
19
|
+
model_name='entry',
|
|
20
|
+
name='_search_text',
|
|
21
|
+
field=models.TextField(blank=True, default=''),
|
|
22
|
+
),
|
|
23
|
+
migrations.AddField(
|
|
24
|
+
model_name='entry',
|
|
25
|
+
name='_search_vector',
|
|
26
|
+
field=django.contrib.postgres.search.SearchVectorField(null=True),
|
|
27
|
+
),
|
|
28
|
+
migrations.AddIndex(
|
|
29
|
+
model_name='entry',
|
|
30
|
+
index=django.contrib.postgres.indexes.GinIndex(fields=['_search_vector'], name='entry_search_vector_idx'),
|
|
31
|
+
),
|
|
32
|
+
migrations.AddIndex(
|
|
33
|
+
model_name='entry',
|
|
34
|
+
index=django.contrib.postgres.indexes.GinIndex(fields=['name'], name='entry_name_trgm_idx', opclasses=['gin_trgm_ops']),
|
|
35
|
+
),
|
|
36
|
+
migrations.AddIndex(
|
|
37
|
+
model_name='entry',
|
|
38
|
+
index=django.contrib.postgres.indexes.GinIndex(fields=['_search_text'], name='entry_search_text_trgm_idx', opclasses=['gin_trgm_ops']),
|
|
39
|
+
),
|
|
40
|
+
]
|
django_spire/knowledge/templates/django_spire/knowledge/message/knowledge_message_intel.html
CHANGED
|
@@ -1,32 +1,40 @@
|
|
|
1
1
|
{% extends 'django_spire/ai/chat/message/message.html' %}
|
|
2
2
|
|
|
3
|
+
{% load spire_ai_chat_tags %}
|
|
4
|
+
|
|
3
5
|
{% block message_content %}
|
|
4
6
|
<div class="row">
|
|
5
|
-
<div class="col">
|
|
6
|
-
{{ message_intel.answer_intel.answer|
|
|
7
|
+
<div class="col ai-message-content">
|
|
8
|
+
{{ message_intel.answer_intel.answer|render_markdown }}
|
|
7
9
|
</div>
|
|
8
10
|
</div>
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
<div class="
|
|
12
|
-
<
|
|
13
|
-
<
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
<ul class="my-0 me-3" x-show="show_resources">
|
|
19
|
-
{% for entry_intel in message_intel.entries_intel %}
|
|
20
|
-
<li class="pb-1">
|
|
21
|
-
<a href="{% url 'django_spire:knowledge:entry:version:page:editor' pk=entry_intel.entry.id %}?show_sub_nav=false&block_id={{ entry_intel.relevant_block_id }}"
|
|
22
|
-
target="_blank">
|
|
23
|
-
{{ entry_intel.relevant_heading_text }}
|
|
24
|
-
</a>
|
|
25
|
-
</li>
|
|
26
|
-
{% endfor %}
|
|
27
|
-
</ul>
|
|
12
|
+
{% if message_intel.entries_intel %}
|
|
13
|
+
<div class="row mt-2 p-1" x-data="{show_resources: false}">
|
|
14
|
+
<div class="col-auto mx-2 ps-1 pe-2 py-1 fs-7 rounded-3 border border-primary-subtle">
|
|
15
|
+
<span class="text-muted cursor-pointer" x-on:click="show_resources = !show_resources">
|
|
16
|
+
<i class="bi bi-caret-right" x-show="!show_resources"></i>
|
|
17
|
+
<i class="bi bi-caret-down" x-show="show_resources"></i>
|
|
18
|
+
Sources
|
|
19
|
+
</span>
|
|
28
20
|
|
|
21
|
+
<ul class="my-0 me-3" x-show="show_resources">
|
|
22
|
+
{% for entry_intel in message_intel.entries_intel.unique %}
|
|
23
|
+
{% if entry_intel.entry and entry_intel.entry.id %}
|
|
24
|
+
<li class="pb-1">
|
|
25
|
+
<a href="{% url 'django_spire:knowledge:entry:version:page:editor' pk=entry_intel.entry.id %}?show_sub_nav=false&block_id={{ entry_intel.relevant_block_id }}"
|
|
26
|
+
target="_blank">
|
|
27
|
+
{{ entry_intel.relevant_heading_text }}
|
|
28
|
+
</a>
|
|
29
|
+
</li>
|
|
30
|
+
{% else %}
|
|
31
|
+
<li class="pb-1 text-muted">
|
|
32
|
+
{{ entry_intel.relevant_heading_text }} (source unavailable)
|
|
33
|
+
</li>
|
|
34
|
+
{% endif %}
|
|
35
|
+
{% endfor %}
|
|
36
|
+
</ul>
|
|
37
|
+
</div>
|
|
29
38
|
</div>
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
{% endblock %}
|
|
39
|
+
{% endif %}
|
|
40
|
+
{% endblock %}
|
|
@@ -1,14 +1,20 @@
|
|
|
1
|
-
from enum import
|
|
1
|
+
from enum import StrEnum
|
|
2
2
|
|
|
3
3
|
|
|
4
|
-
class ColumnType(
|
|
4
|
+
class ColumnType(StrEnum):
|
|
5
5
|
TEXT = 'text'
|
|
6
6
|
CHOICE = 'choice'
|
|
7
7
|
NUMBER = 'number'
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
NUMBER_1 = 'decimal_1'
|
|
9
|
+
NUMBER_2 = 'decimal_2'
|
|
10
|
+
NUMBER_3 = 'decimal_3'
|
|
11
11
|
DOLLAR = 'dollar'
|
|
12
|
+
DOLLAR_1 = 'dollar_1'
|
|
13
|
+
DOLLAR_2 = 'dollar_2'
|
|
14
|
+
DOLLAR_3 = 'dollar_3'
|
|
12
15
|
PERCENT = 'percent'
|
|
16
|
+
PERCENT_1 = 'percent_1'
|
|
17
|
+
PERCENT_2 = 'percent_2'
|
|
18
|
+
PERCENT_3 = 'percent_3'
|
|
13
19
|
|
|
14
20
|
|
|
@@ -2,14 +2,12 @@ import inspect
|
|
|
2
2
|
from abc import ABC, abstractmethod
|
|
3
3
|
from dataclasses import dataclass
|
|
4
4
|
from dataclasses import field
|
|
5
|
-
from typing import
|
|
5
|
+
from typing import Callable, Any
|
|
6
6
|
|
|
7
7
|
from django_spire.metric.report.enums import ColumnType
|
|
8
8
|
from django_spire.metric.report.helper import Helper
|
|
9
9
|
from django_spire.metric.report.tools import get_text_alignment_css_class
|
|
10
10
|
|
|
11
|
-
ColumnLiteralType = Literal['text', 'choice', 'number', 'dollar', 'percent']
|
|
12
|
-
|
|
13
11
|
|
|
14
12
|
@dataclass
|
|
15
13
|
class ReportColumn:
|
|
@@ -35,17 +33,31 @@ class ReportCell:
|
|
|
35
33
|
@staticmethod
|
|
36
34
|
def cell_value_verbose(value, cell_type):
|
|
37
35
|
if cell_type == ColumnType.DOLLAR:
|
|
38
|
-
return f"${float(value):,.
|
|
36
|
+
return f"${float(value):,.0f}"
|
|
37
|
+
elif cell_type == ColumnType.DOLLAR_1:
|
|
38
|
+
return f"${float(value):,.1f}%"
|
|
39
|
+
elif cell_type == ColumnType.DOLLAR_2:
|
|
40
|
+
return f"${float(value):,.2f}%"
|
|
41
|
+
elif cell_type == ColumnType.DOLLAR_3:
|
|
42
|
+
return f"${float(value):,.3f}%"
|
|
43
|
+
|
|
44
|
+
elif cell_type == ColumnType.PERCENT:
|
|
45
|
+
return f"{float(value):,.0f}%"
|
|
46
|
+
elif cell_type == ColumnType.PERCENT_1:
|
|
47
|
+
return f"{float(value):,.1f}%"
|
|
48
|
+
elif cell_type == ColumnType.PERCENT_2:
|
|
49
|
+
return f"{float(value):,.2f}%"
|
|
50
|
+
elif cell_type == ColumnType.PERCENT_3:
|
|
51
|
+
return f"{float(value):,.3f}%"
|
|
52
|
+
|
|
39
53
|
elif cell_type == ColumnType.NUMBER:
|
|
40
54
|
return f"{float(value):,.0f}"
|
|
41
|
-
elif cell_type == ColumnType.
|
|
42
|
-
return f"{float(value)
|
|
43
|
-
elif cell_type == ColumnType.
|
|
44
|
-
return f"{float(value)
|
|
45
|
-
elif cell_type == ColumnType.
|
|
46
|
-
return f"{float(value)
|
|
47
|
-
elif cell_type == ColumnType.DECIMAL_3:
|
|
48
|
-
return f"{float(value):.3f}"
|
|
55
|
+
elif cell_type == ColumnType.NUMBER_1:
|
|
56
|
+
return f"{float(value):,.1f}"
|
|
57
|
+
elif cell_type == ColumnType.NUMBER_2:
|
|
58
|
+
return f"{float(value):,.2f}"
|
|
59
|
+
elif cell_type == ColumnType.NUMBER_3:
|
|
60
|
+
return f"{float(value):,.3f}"
|
|
49
61
|
|
|
50
62
|
return str(value)
|
|
51
63
|
|
|
@@ -4,8 +4,20 @@ from django_spire.metric.report.enums import ColumnType
|
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
def get_text_alignment_css_class(column_type: ColumnType) -> str:
|
|
7
|
-
if column_type in (
|
|
8
|
-
|
|
7
|
+
if column_type in (
|
|
8
|
+
ColumnType.DOLLAR,
|
|
9
|
+
ColumnType.DOLLAR_1,
|
|
10
|
+
ColumnType.DOLLAR_2,
|
|
11
|
+
ColumnType.DOLLAR_3,
|
|
12
|
+
ColumnType.PERCENT,
|
|
13
|
+
ColumnType.PERCENT_1,
|
|
14
|
+
ColumnType.PERCENT_2,
|
|
15
|
+
ColumnType.PERCENT_3,
|
|
16
|
+
ColumnType.NUMBER,
|
|
17
|
+
ColumnType.NUMBER_1,
|
|
18
|
+
ColumnType.NUMBER_2,
|
|
19
|
+
ColumnType.NUMBER_3
|
|
20
|
+
):
|
|
9
21
|
return 'text-end'
|
|
10
22
|
if column_type == ColumnType.CHOICE:
|
|
11
23
|
return 'text-center'
|
|
@@ -5,16 +5,11 @@ import pytest
|
|
|
5
5
|
|
|
6
6
|
from typing import Any, TYPE_CHECKING
|
|
7
7
|
|
|
8
|
-
from django.contrib.auth import get_user_model
|
|
9
|
-
|
|
10
8
|
if TYPE_CHECKING:
|
|
11
9
|
from playwright.sync_api import Page
|
|
12
10
|
from pytest_django.plugin import _LiveServer
|
|
13
11
|
|
|
14
12
|
|
|
15
|
-
User = get_user_model()
|
|
16
|
-
|
|
17
|
-
|
|
18
13
|
def pytest_configure(config: Any) -> None:
|
|
19
14
|
os.environ['DJANGO_ALLOW_ASYNC_UNSAFE'] = 'true'
|
|
20
15
|
|
|
@@ -38,6 +33,10 @@ def browser_type_launch_args(browser_type_launch_args: dict[str, Any]) -> dict[s
|
|
|
38
33
|
|
|
39
34
|
@pytest.fixture
|
|
40
35
|
def authenticated_page(page: Page, live_server: _LiveServer, transactional_db: None) -> Page:
|
|
36
|
+
from django.contrib.auth import get_user_model
|
|
37
|
+
|
|
38
|
+
User = get_user_model()
|
|
39
|
+
|
|
41
40
|
User.objects.create_user(
|
|
42
41
|
username='testuser',
|
|
43
42
|
password='testpass123',
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: django-spire
|
|
3
|
-
Version: 0.25.
|
|
3
|
+
Version: 0.25.2
|
|
4
4
|
Summary: A project for Django Spire
|
|
5
5
|
Author-email: Brayden Carlson <braydenc@stratusadv.com>, Nathan Johnson <nathanj@stratusadv.com>
|
|
6
6
|
License: Copyright (c) 2025 Stratus Advanced Technologies and Contributors.
|