unique_toolkit 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- unique_toolkit/__init__.py +3 -0
- unique_toolkit/chat/__init__.py +3 -0
- unique_toolkit/chat/messages.py +24 -0
- unique_toolkit/chat/service.py +182 -0
- unique_toolkit/chat/state.py +48 -0
- unique_toolkit/event/__init__.py +1 -0
- unique_toolkit/event/constants.py +1 -0
- unique_toolkit/event/schema.py +47 -0
- unique_toolkit/llm/__init__.py +2 -0
- unique_toolkit/llm/json_parser.py +21 -0
- unique_toolkit/llm/models.py +37 -0
- unique_toolkit/llm/service.py +163 -0
- unique_toolkit/observability/log_config.py +31 -0
- unique_toolkit/performance/__init__.py +1 -0
- unique_toolkit/performance/async_executor.py +159 -0
- unique_toolkit/performance/async_wrapper.py +13 -0
- unique_toolkit/sdk/init.py +19 -0
- unique_toolkit/search/service.py +118 -0
- unique_toolkit/security/verify.py +29 -0
- unique_toolkit-0.0.1.dist-info/LICENSE +21 -0
- unique_toolkit-0.0.1.dist-info/METADATA +33 -0
- unique_toolkit-0.0.1.dist-info/RECORD +23 -0
- unique_toolkit-0.0.1.dist-info/WHEEL +4 -0
@@ -0,0 +1,24 @@
|
|
1
|
+
from enum import StrEnum
|
2
|
+
from typing import List
|
3
|
+
|
4
|
+
from pydantic import BaseModel, RootModel
|
5
|
+
|
6
|
+
|
7
|
+
class AIMessageRole(StrEnum):
|
8
|
+
USER = "user"
|
9
|
+
SYSTEM = "system"
|
10
|
+
|
11
|
+
|
12
|
+
class AIMessage(BaseModel):
|
13
|
+
role: AIMessageRole
|
14
|
+
content: str
|
15
|
+
|
16
|
+
|
17
|
+
class AIMessageList(RootModel):
|
18
|
+
root: List[AIMessage]
|
19
|
+
|
20
|
+
def __iter__(self):
|
21
|
+
return iter(self.root)
|
22
|
+
|
23
|
+
def __getitem__(self, item):
|
24
|
+
return self.root[item]
|
@@ -0,0 +1,182 @@
|
|
1
|
+
import unique_sdk
|
2
|
+
from unique_sdk.utils.chat_history import load_history
|
3
|
+
|
4
|
+
from unique_toolkit.performance.async_wrapper import to_async
|
5
|
+
|
6
|
+
from .state import ChatState
|
7
|
+
|
8
|
+
|
9
|
+
class ChatService:
|
10
|
+
"""
|
11
|
+
Provides all functionalities to manage the chat session.
|
12
|
+
|
13
|
+
Attributes:
|
14
|
+
state (ChatState): The chat state.
|
15
|
+
"""
|
16
|
+
|
17
|
+
def __init__(self, state: ChatState):
|
18
|
+
self.state = state
|
19
|
+
|
20
|
+
def modify_assistant_message(
|
21
|
+
self,
|
22
|
+
*,
|
23
|
+
text: str,
|
24
|
+
references: list = [],
|
25
|
+
debug_info: dict = {},
|
26
|
+
) -> None:
|
27
|
+
"""
|
28
|
+
Modifies a message in the chat session synchronously.
|
29
|
+
|
30
|
+
Args:
|
31
|
+
text (str): The new text for the message.
|
32
|
+
references (Optional[list[dict[str, Any]]], optional): list of references. Defaults to None.
|
33
|
+
debug_info (Optional[dict[str, Any]]], optional): Debug information. Defaults to None.
|
34
|
+
"""
|
35
|
+
self._trigger_modify_assistant_message(text, references, debug_info)
|
36
|
+
|
37
|
+
@to_async
|
38
|
+
def async_modify_assistant_message(
|
39
|
+
self,
|
40
|
+
*,
|
41
|
+
text: str,
|
42
|
+
references: list = [],
|
43
|
+
debug_info: dict = {},
|
44
|
+
) -> None:
|
45
|
+
"""
|
46
|
+
Modifies a message in the chat session asynchronously.
|
47
|
+
|
48
|
+
Args:
|
49
|
+
text (str): The new text for the message.
|
50
|
+
references (Optional[list[dict[str, Any]]], optional): list of references. Defaults to None.
|
51
|
+
debug_info (Optional[dict[str, Any]]], optional): Debug information. Defaults to None.
|
52
|
+
"""
|
53
|
+
self._trigger_modify_assistant_message(text, references, debug_info)
|
54
|
+
|
55
|
+
def get_history(
|
56
|
+
self,
|
57
|
+
*,
|
58
|
+
max_tokens: int,
|
59
|
+
percent_of_max_tokens: float,
|
60
|
+
max_messages: int,
|
61
|
+
):
|
62
|
+
"""
|
63
|
+
Loads the chat history for the chat session synchronously.
|
64
|
+
|
65
|
+
Args:
|
66
|
+
max_tokens (int): The maximum number of tokens to load.
|
67
|
+
percent_of_max_tokens (float): The percentage of the maximum tokens to load.
|
68
|
+
max_messages (int): The maximum number of messages to load.
|
69
|
+
|
70
|
+
Returns:
|
71
|
+
list[dict[str, Any]]: The chat history.
|
72
|
+
"""
|
73
|
+
return self._trigger_load_history(
|
74
|
+
max_tokens, percent_of_max_tokens, max_messages
|
75
|
+
)
|
76
|
+
|
77
|
+
@to_async
|
78
|
+
def async_get_history(
|
79
|
+
self,
|
80
|
+
*,
|
81
|
+
max_tokens: int,
|
82
|
+
percent_of_max_tokens: float,
|
83
|
+
max_messages: int,
|
84
|
+
):
|
85
|
+
"""
|
86
|
+
Loads the chat history for the chat session asynchronously.
|
87
|
+
|
88
|
+
Args:
|
89
|
+
max_tokens (int): The maximum number of tokens to load.
|
90
|
+
percent_of_max_tokens (float): The percentage of the maximum tokens to load.
|
91
|
+
max_messages (int): The maximum number of messages to load.
|
92
|
+
|
93
|
+
Returns:
|
94
|
+
list[dict[str, Any]]: The chat history.
|
95
|
+
"""
|
96
|
+
return self._trigger_load_history(
|
97
|
+
max_tokens, percent_of_max_tokens, max_messages
|
98
|
+
)
|
99
|
+
|
100
|
+
def create_assistant_message(
|
101
|
+
self,
|
102
|
+
*,
|
103
|
+
text: str,
|
104
|
+
references: list = [],
|
105
|
+
debug_info: dict = {},
|
106
|
+
):
|
107
|
+
"""
|
108
|
+
Creates a message in the chat session synchronously.
|
109
|
+
|
110
|
+
Args:
|
111
|
+
text (str): The text for the message.
|
112
|
+
references (Optional[list[dict[str, Any]]], optional): list of references. Defaults to None.
|
113
|
+
debug_info (Optional[dict[str, Any]]], optional): Debug information. Defaults to None.
|
114
|
+
"""
|
115
|
+
return self._trigger_create_assistant_message(text, references, debug_info)
|
116
|
+
|
117
|
+
@to_async
|
118
|
+
def async_create_assistant_message(
|
119
|
+
self,
|
120
|
+
*,
|
121
|
+
text: str,
|
122
|
+
references: list = [],
|
123
|
+
debug_info: dict = {},
|
124
|
+
):
|
125
|
+
"""
|
126
|
+
Creates a message in the chat session asynchronously.
|
127
|
+
|
128
|
+
Args:
|
129
|
+
text (str): The text for the message.
|
130
|
+
references (Optional[list[dict[str, Any]]], optional): list of references. Defaults to None.
|
131
|
+
debug_info (Optional[dict[str, Any]]], optional): Debug information. Defaults to None.
|
132
|
+
"""
|
133
|
+
return self._trigger_create_assistant_message(text, references, debug_info)
|
134
|
+
|
135
|
+
def _trigger_modify_assistant_message(
|
136
|
+
self,
|
137
|
+
text: str,
|
138
|
+
references: list = [],
|
139
|
+
debug_info: dict = {},
|
140
|
+
) -> None:
|
141
|
+
unique_sdk.Message.modify(
|
142
|
+
user_id=self.state.user_id,
|
143
|
+
company_id=self.state.company_id,
|
144
|
+
id=self.state.assistant_message_id,
|
145
|
+
chatId=self.state.chat_id,
|
146
|
+
text=text,
|
147
|
+
references=references or [],
|
148
|
+
debugInfo=debug_info or {},
|
149
|
+
)
|
150
|
+
|
151
|
+
def _trigger_load_history(
|
152
|
+
self,
|
153
|
+
max_tokens: int,
|
154
|
+
percent_of_max_tokens: float,
|
155
|
+
max_messages: int,
|
156
|
+
):
|
157
|
+
return load_history(
|
158
|
+
self.state.user_id,
|
159
|
+
self.state.company_id,
|
160
|
+
self.state.chat_id,
|
161
|
+
max_tokens,
|
162
|
+
percent_of_max_tokens,
|
163
|
+
max_messages,
|
164
|
+
)
|
165
|
+
|
166
|
+
# TODO throws error at the moment
|
167
|
+
def _trigger_create_assistant_message(
|
168
|
+
self,
|
169
|
+
text: str,
|
170
|
+
references: list = [],
|
171
|
+
debug_info: dict = {},
|
172
|
+
):
|
173
|
+
return unique_sdk.Message.create(
|
174
|
+
user_id=self.state.user_id,
|
175
|
+
company_id=self.state.company_id,
|
176
|
+
chatId=self.state.chat_id,
|
177
|
+
assistantId=self.state.assistant_id,
|
178
|
+
text=text,
|
179
|
+
role="ASSISTANT",
|
180
|
+
references=references,
|
181
|
+
debugInfo=debug_info,
|
182
|
+
)
|
@@ -0,0 +1,48 @@
|
|
1
|
+
from dataclasses import dataclass
|
2
|
+
from typing import Self
|
3
|
+
|
4
|
+
from unique_toolkit.event.schema import Event
|
5
|
+
|
6
|
+
|
7
|
+
@dataclass
|
8
|
+
class ChatState:
|
9
|
+
"""
|
10
|
+
Represents the state of the chat session.
|
11
|
+
|
12
|
+
Attributes:
|
13
|
+
company_id (str): The company ID.
|
14
|
+
user_id (str): The user ID.
|
15
|
+
chat_id (str): The chat ID.
|
16
|
+
user_message_text (str): The user message text.
|
17
|
+
user_message_id (str): The user message ID.
|
18
|
+
assistant_message_id (str): The assistant message ID.
|
19
|
+
"""
|
20
|
+
|
21
|
+
company_id: str
|
22
|
+
user_id: str
|
23
|
+
assistant_id: str = ""
|
24
|
+
chat_id: str = ""
|
25
|
+
user_message_text: str = ""
|
26
|
+
user_message_id: str = ""
|
27
|
+
assistant_message_id: str = ""
|
28
|
+
|
29
|
+
@classmethod
|
30
|
+
def from_event(cls, event: Event) -> Self:
|
31
|
+
"""
|
32
|
+
Creates a ChatState instance from an event dictionary.
|
33
|
+
|
34
|
+
Args:
|
35
|
+
event (dict): The event dictionary.
|
36
|
+
|
37
|
+
Returns:
|
38
|
+
ChatState: The ChatState instance.
|
39
|
+
"""
|
40
|
+
# Chat only flag
|
41
|
+
return cls(
|
42
|
+
user_id=event.user_id,
|
43
|
+
chat_id=event.payload.chat_id,
|
44
|
+
company_id=event.company_id,
|
45
|
+
user_message_text=event.payload.user_message.text,
|
46
|
+
user_message_id=event.payload.user_message.id,
|
47
|
+
assistant_message_id=event.payload.assistant_message.id,
|
48
|
+
)
|
@@ -0,0 +1 @@
|
|
1
|
+
from .schema import AssistantMessage, Event, Payload, UserMessage # noqa: F401
|
@@ -0,0 +1 @@
|
|
1
|
+
EXTERNAL_MODULE_EVENT_NAME = "unique.chat.external-module.chosen"
|
@@ -0,0 +1,47 @@
|
|
1
|
+
from typing import Any
|
2
|
+
|
3
|
+
from humps import camelize
|
4
|
+
from pydantic import BaseModel, ConfigDict
|
5
|
+
|
6
|
+
# set config to convert camelCase to snake_case
|
7
|
+
model_config = ConfigDict(alias_generator=camelize, populate_by_name=True)
|
8
|
+
|
9
|
+
|
10
|
+
class UserMessage(BaseModel):
|
11
|
+
model_config = model_config
|
12
|
+
|
13
|
+
id: str
|
14
|
+
text: str
|
15
|
+
created_at: str
|
16
|
+
|
17
|
+
|
18
|
+
class AssistantMessage(BaseModel):
|
19
|
+
model_config = model_config
|
20
|
+
|
21
|
+
id: str
|
22
|
+
created_at: str
|
23
|
+
|
24
|
+
|
25
|
+
class Payload(BaseModel):
|
26
|
+
model_config = model_config
|
27
|
+
|
28
|
+
name: str
|
29
|
+
description: str
|
30
|
+
configuration: dict[str, Any]
|
31
|
+
chat_id: str
|
32
|
+
assistant_id: str
|
33
|
+
user_message: UserMessage
|
34
|
+
assistant_message: AssistantMessage
|
35
|
+
text: str | None = None
|
36
|
+
|
37
|
+
|
38
|
+
class Event(BaseModel):
|
39
|
+
model_config = model_config
|
40
|
+
|
41
|
+
id: str
|
42
|
+
version: str
|
43
|
+
event: str
|
44
|
+
created_at: int
|
45
|
+
user_id: str
|
46
|
+
company_id: str
|
47
|
+
payload: Payload
|
@@ -0,0 +1,21 @@
|
|
1
|
+
import json
|
2
|
+
import re
|
3
|
+
|
4
|
+
|
5
|
+
def convert_to_json(result: str):
|
6
|
+
"""
|
7
|
+
Removes any json tags and converts string to json.
|
8
|
+
"""
|
9
|
+
cleaned_result = find_last_json_object(result)
|
10
|
+
if not cleaned_result:
|
11
|
+
raise ValueError("Could not find a valid json object in the result.")
|
12
|
+
return json.loads(cleaned_result)
|
13
|
+
|
14
|
+
|
15
|
+
def find_last_json_object(text) -> str | None:
|
16
|
+
pattern = r"\{(?:[^{}]|\{[^{}]*\})*\}"
|
17
|
+
matches = re.findall(pattern, text)
|
18
|
+
if matches:
|
19
|
+
return matches[-1]
|
20
|
+
else:
|
21
|
+
return None
|
@@ -0,0 +1,37 @@
|
|
1
|
+
from enum import StrEnum
|
2
|
+
|
3
|
+
from pydantic import BaseModel
|
4
|
+
|
5
|
+
|
6
|
+
class LLMModelName(StrEnum):
|
7
|
+
AZURE_GPT_35_TURBO_0613 = "AZURE_GPT_35_TURBO_0613"
|
8
|
+
AZURE_GPT_35_TURBO = "AZURE_GPT_35_TURBO"
|
9
|
+
AZURE_GPT_35_TURBO_16K = "AZURE_GPT_35_TURBO_16K"
|
10
|
+
AZURE_GPT_4_0613 = "AZURE_GPT_4_0613"
|
11
|
+
AZURE_GPT_4_TURBO_1106 = "AZURE_GPT_4_TURBO_1106"
|
12
|
+
AZURE_GPT_4_VISION_PREVIEW = "AZURE_GPT_4_VISION_PREVIEW"
|
13
|
+
AZURE_GPT_4_32K_0613 = "AZURE_GPT_4_32K_0613"
|
14
|
+
AZURE_GPT_4_TURBO_2024_0409 = "AZURE_GPT_4_TURBO_2024_0409"
|
15
|
+
|
16
|
+
|
17
|
+
class LLMModelInfo(BaseModel):
|
18
|
+
name: LLMModelName
|
19
|
+
max_tokens: int
|
20
|
+
|
21
|
+
|
22
|
+
llm_token_limit_map = {
|
23
|
+
LLMModelName.AZURE_GPT_35_TURBO_0613: 3000,
|
24
|
+
LLMModelName.AZURE_GPT_35_TURBO: 3000,
|
25
|
+
LLMModelName.AZURE_GPT_35_TURBO_16K: 14000,
|
26
|
+
LLMModelName.AZURE_GPT_4_0613: 7000,
|
27
|
+
LLMModelName.AZURE_GPT_4_TURBO_1106: 7000,
|
28
|
+
LLMModelName.AZURE_GPT_4_VISION_PREVIEW: 7000,
|
29
|
+
LLMModelName.AZURE_GPT_4_32K_0613: 30000,
|
30
|
+
LLMModelName.AZURE_GPT_4_TURBO_2024_0409: 7000,
|
31
|
+
}
|
32
|
+
|
33
|
+
|
34
|
+
def get_llm_model_info(model_name: LLMModelName) -> LLMModelInfo:
|
35
|
+
if model_name not in llm_token_limit_map:
|
36
|
+
raise ValueError(f"Model {model_name} not found.")
|
37
|
+
return LLMModelInfo(name=model_name, max_tokens=llm_token_limit_map[model_name])
|
@@ -0,0 +1,163 @@
|
|
1
|
+
import unique_sdk
|
2
|
+
|
3
|
+
from unique_toolkit.chat.state import ChatState
|
4
|
+
from unique_toolkit.performance.async_wrapper import to_async
|
5
|
+
|
6
|
+
from .models import LLMModelName
|
7
|
+
|
8
|
+
|
9
|
+
class LLMService:
|
10
|
+
def __init__(self, state: ChatState):
|
11
|
+
self.state = state
|
12
|
+
|
13
|
+
def stream_complete(
|
14
|
+
self,
|
15
|
+
*,
|
16
|
+
messages: list,
|
17
|
+
model_name: LLMModelName,
|
18
|
+
search_contexts: list = [],
|
19
|
+
debug_info: dict = {},
|
20
|
+
timeout: int = 100_000,
|
21
|
+
temperature: float = 0.25,
|
22
|
+
):
|
23
|
+
"""
|
24
|
+
Streams a completion in the chat session synchronously.
|
25
|
+
|
26
|
+
Args:
|
27
|
+
messages (list): The messages to stream.
|
28
|
+
search_contexts (list): The search context.
|
29
|
+
model_name (LLMModelName): The language model to use for the completion.
|
30
|
+
debug_info (dict): The debug information.
|
31
|
+
timeout (int, optional): The timeout value in milliseconds. Defaults to 100_000.
|
32
|
+
temperature (float, optional): The temperature value for the completion. Defaults to 0.25.
|
33
|
+
|
34
|
+
Returns:
|
35
|
+
A generator yielding streamed completion chunks.
|
36
|
+
"""
|
37
|
+
return self._trigger_stream_complete(
|
38
|
+
messages,
|
39
|
+
search_contexts,
|
40
|
+
model_name.name,
|
41
|
+
debug_info,
|
42
|
+
timeout,
|
43
|
+
temperature,
|
44
|
+
)
|
45
|
+
|
46
|
+
@to_async
|
47
|
+
def async_stream_complete(
|
48
|
+
self,
|
49
|
+
*,
|
50
|
+
messages: list,
|
51
|
+
model_name: LLMModelName,
|
52
|
+
search_contexts: list = [],
|
53
|
+
debug_info: dict = {},
|
54
|
+
timeout: int = 100_000,
|
55
|
+
temperature: float = 0.25,
|
56
|
+
):
|
57
|
+
"""
|
58
|
+
Streams a completion in the chat session asynchronously.
|
59
|
+
|
60
|
+
Args:
|
61
|
+
messages (list[dict[str, str]]): The messages to stream.
|
62
|
+
search_contexts (list): The search context.
|
63
|
+
debug_info (dict): The debug information.
|
64
|
+
model_name (LLMModelName): The language model to use for the completion.
|
65
|
+
timeout (int, optional): The timeout value in milliseconds. Defaults to 100_000.
|
66
|
+
temperature (float, optional): The temperature value for the completion. Defaults to 0.25.
|
67
|
+
|
68
|
+
Returns:
|
69
|
+
A generator yielding streamed completion chunks.
|
70
|
+
"""
|
71
|
+
return self._trigger_stream_complete(
|
72
|
+
messages, search_contexts, model_name, debug_info, timeout, temperature
|
73
|
+
)
|
74
|
+
|
75
|
+
def complete(
|
76
|
+
self,
|
77
|
+
*,
|
78
|
+
messages: list,
|
79
|
+
model_name: LLMModelName,
|
80
|
+
temperature: float = 0,
|
81
|
+
timeout: int = 240000,
|
82
|
+
) -> str:
|
83
|
+
"""
|
84
|
+
Calls the completion endpoint synchronously without streaming the response.
|
85
|
+
|
86
|
+
Args:
|
87
|
+
messages (list[dict[str, str]]): The messages to complete.
|
88
|
+
model_name (LLMModelName): The model name.
|
89
|
+
temperature (float, optional): The temperature value. Defaults to 0.
|
90
|
+
timeout (int, optional): The timeout value in milliseconds. Defaults to 240000.
|
91
|
+
|
92
|
+
Returns:
|
93
|
+
str: The completed message content.
|
94
|
+
"""
|
95
|
+
return self._trigger_complete(messages, model_name, temperature, timeout)
|
96
|
+
|
97
|
+
@to_async
|
98
|
+
def async_complete(
|
99
|
+
self,
|
100
|
+
*,
|
101
|
+
messages: list,
|
102
|
+
model_name: LLMModelName,
|
103
|
+
temperature: float = 0,
|
104
|
+
timeout: int = 240000,
|
105
|
+
) -> str:
|
106
|
+
"""
|
107
|
+
Calls the completion endpoint asynchronously without streaming the response.
|
108
|
+
|
109
|
+
Args:
|
110
|
+
messages (list[dict[str, str]]): The messages to complete.
|
111
|
+
model_name (LLMModelName): The model name.
|
112
|
+
temperature (float, optional): The temperature value. Defaults to 0.
|
113
|
+
timeout (int, optional): The timeout value in milliseconds. Defaults to 240000.
|
114
|
+
|
115
|
+
Returns:
|
116
|
+
str: The completed message content.
|
117
|
+
"""
|
118
|
+
return self._trigger_complete(
|
119
|
+
messages,
|
120
|
+
model_name.name,
|
121
|
+
temperature,
|
122
|
+
timeout,
|
123
|
+
)
|
124
|
+
|
125
|
+
def _trigger_stream_complete(
|
126
|
+
self,
|
127
|
+
messages: list,
|
128
|
+
search_contexts: list,
|
129
|
+
model_name: str,
|
130
|
+
debug_info: dict,
|
131
|
+
timeout: int,
|
132
|
+
temperature: float,
|
133
|
+
):
|
134
|
+
return unique_sdk.Integrated.chat_stream_completion(
|
135
|
+
user_id=self.state.user_id,
|
136
|
+
company_id=self.state.company_id,
|
137
|
+
assistantMessageId=self.state.assistant_message_id,
|
138
|
+
userMessageId=self.state.user_message_id,
|
139
|
+
messages=messages,
|
140
|
+
chatId=self.state.chat_id,
|
141
|
+
searchContext=search_contexts,
|
142
|
+
debugInfo=debug_info,
|
143
|
+
model=model_name, # type: ignore
|
144
|
+
timeout=timeout,
|
145
|
+
temperature=temperature,
|
146
|
+
assistantId=self.state.assistant_id,
|
147
|
+
)
|
148
|
+
|
149
|
+
def _trigger_complete(
|
150
|
+
self,
|
151
|
+
messages: list,
|
152
|
+
model_name: str,
|
153
|
+
temperature: float,
|
154
|
+
timeout: int,
|
155
|
+
) -> str:
|
156
|
+
result = unique_sdk.ChatCompletion.create(
|
157
|
+
company_id=self.state.company_id,
|
158
|
+
model=model_name, # type: ignore
|
159
|
+
messages=messages,
|
160
|
+
timeout=timeout,
|
161
|
+
temperature=temperature,
|
162
|
+
)
|
163
|
+
return result.choices[-1]["message"]["content"]
|
@@ -0,0 +1,31 @@
|
|
1
|
+
from logging import Formatter
|
2
|
+
from logging.config import dictConfig
|
3
|
+
from time import gmtime
|
4
|
+
|
5
|
+
|
6
|
+
class UTCFormatter(Formatter):
|
7
|
+
converter = gmtime
|
8
|
+
|
9
|
+
|
10
|
+
log_config = {
|
11
|
+
"version": 1,
|
12
|
+
"root": {"level": "DEBUG", "handlers": ["console"]},
|
13
|
+
"handlers": {
|
14
|
+
"console": {
|
15
|
+
"class": "logging.StreamHandler",
|
16
|
+
"level": "DEBUG",
|
17
|
+
"formatter": "utc",
|
18
|
+
},
|
19
|
+
"formatters": {
|
20
|
+
"utc": {
|
21
|
+
"()": UTCFormatter,
|
22
|
+
"format": "%(asctime)s: %(message)s",
|
23
|
+
"datefmt": "%Y-%m-%d %H:%M:%S",
|
24
|
+
},
|
25
|
+
},
|
26
|
+
},
|
27
|
+
}
|
28
|
+
|
29
|
+
|
30
|
+
def load_logging_config():
|
31
|
+
return dictConfig(log_config)
|
@@ -0,0 +1 @@
|
|
1
|
+
from .async_wrapper import to_async # noqa: F401
|
@@ -0,0 +1,159 @@
|
|
1
|
+
import asyncio
|
2
|
+
import threading
|
3
|
+
import time
|
4
|
+
from concurrent.futures import ThreadPoolExecutor
|
5
|
+
from math import ceil
|
6
|
+
from typing import Awaitable, Sequence, TypeVar, Union
|
7
|
+
|
8
|
+
from quart import Quart
|
9
|
+
|
10
|
+
T = TypeVar("T")
|
11
|
+
Result = Union[T, BaseException]
|
12
|
+
|
13
|
+
|
14
|
+
class AsyncExecutor:
|
15
|
+
def __init__(self, app_instance: Quart) -> None:
|
16
|
+
self.app = app_instance
|
17
|
+
|
18
|
+
async def run_async_tasks(
|
19
|
+
self,
|
20
|
+
tasks: Sequence[Awaitable[T]],
|
21
|
+
max_tasks: int,
|
22
|
+
) -> list[Result]:
|
23
|
+
"""
|
24
|
+
Executes the given async tasks within one thread non-blocking and returns the results.
|
25
|
+
|
26
|
+
Args:
|
27
|
+
tasks (list[Awaitable[T]]): list of async callables to execute in parallel.
|
28
|
+
max_tasks (int): Maximum number of tasks for the asyncio Semaphore.
|
29
|
+
|
30
|
+
Returns:
|
31
|
+
list[Result]: list of results from the executed tasks.
|
32
|
+
"""
|
33
|
+
|
34
|
+
async def logging_wrapper(task: Awaitable[T], task_id: int) -> Result:
|
35
|
+
thread = threading.current_thread()
|
36
|
+
start_time = time.time()
|
37
|
+
|
38
|
+
self.app.logger.info(
|
39
|
+
f"Thread {thread.name} (ID: {thread.ident}) starting task {task_id}"
|
40
|
+
)
|
41
|
+
|
42
|
+
try:
|
43
|
+
async with self.app.app_context():
|
44
|
+
result = await task
|
45
|
+
return result
|
46
|
+
except Exception as e:
|
47
|
+
self.app.logger.error(
|
48
|
+
f"Thread {thread.name} (ID: {thread.ident}) - Task {task_id} failed with error: {e}"
|
49
|
+
)
|
50
|
+
return e
|
51
|
+
finally:
|
52
|
+
end_time = time.time()
|
53
|
+
duration = end_time - start_time
|
54
|
+
self.app.logger.debug(
|
55
|
+
f"Thread {thread.name} (ID: {thread.ident}) - Task {task_id} finished in {duration:.2f} seconds"
|
56
|
+
)
|
57
|
+
|
58
|
+
sem = asyncio.Semaphore(max_tasks)
|
59
|
+
|
60
|
+
async def sem_task(task: Awaitable[T], task_id: int) -> Result:
|
61
|
+
async with sem:
|
62
|
+
return await logging_wrapper(task, task_id)
|
63
|
+
|
64
|
+
wrapped_tasks: list[Awaitable[Result]] = [
|
65
|
+
sem_task(task, i) for i, task in enumerate(tasks)
|
66
|
+
]
|
67
|
+
|
68
|
+
results: list[Result] = await asyncio.gather(
|
69
|
+
*wrapped_tasks, return_exceptions=True
|
70
|
+
)
|
71
|
+
|
72
|
+
return results
|
73
|
+
|
74
|
+
async def run_async_tasks_in_threads(
|
75
|
+
self,
|
76
|
+
tasks: Sequence[Awaitable[T]],
|
77
|
+
max_threads: int,
|
78
|
+
max_tasks: int,
|
79
|
+
) -> list[Result[T]]:
|
80
|
+
"""
|
81
|
+
Executes the given async tasks in parallel threads and returns the results.
|
82
|
+
|
83
|
+
Args:
|
84
|
+
tasks (list[Awaitable[T]]): list of async callables to execute in parallel.
|
85
|
+
max_threads (int): Maximum number of threads.
|
86
|
+
max_tasks (int): Maximum number of tasks per thread run in parallel.
|
87
|
+
|
88
|
+
Returns:
|
89
|
+
list[Result]: list of results from the executed tasks.
|
90
|
+
"""
|
91
|
+
|
92
|
+
async def run_in_thread(task_chunk: list[Awaitable[T]]) -> list[Result]:
|
93
|
+
loop = asyncio.new_event_loop()
|
94
|
+
asyncio.set_event_loop(loop)
|
95
|
+
async with self.app.app_context():
|
96
|
+
return await self.run_async_tasks(task_chunk, max_tasks)
|
97
|
+
|
98
|
+
def thread_worker(
|
99
|
+
task_chunk: list[Awaitable[T]], chunk_id: int
|
100
|
+
) -> list[Result]:
|
101
|
+
thread = threading.current_thread()
|
102
|
+
self.app.logger.info(
|
103
|
+
f"Thread {thread.name} (ID: {thread.ident}) starting chunk {chunk_id} with {len(task_chunk)} tasks"
|
104
|
+
)
|
105
|
+
|
106
|
+
start_time = time.time()
|
107
|
+
loop = asyncio.new_event_loop()
|
108
|
+
asyncio.set_event_loop(loop)
|
109
|
+
|
110
|
+
try:
|
111
|
+
results = loop.run_until_complete(run_in_thread(task_chunk))
|
112
|
+
end_time = time.time()
|
113
|
+
duration = end_time - start_time
|
114
|
+
self.app.logger.info(
|
115
|
+
f"Thread {thread.name} (ID: {thread.ident}) finished chunk {chunk_id} in {duration:.2f} seconds"
|
116
|
+
)
|
117
|
+
return results
|
118
|
+
except Exception as e:
|
119
|
+
self.app.logger.error(
|
120
|
+
f"Thread {thread.name} (ID: {thread.ident}) encountered an error in chunk {chunk_id}: {str(e)}"
|
121
|
+
)
|
122
|
+
raise
|
123
|
+
finally:
|
124
|
+
loop.close()
|
125
|
+
|
126
|
+
start_time = time.time()
|
127
|
+
# Calculate the number of tasks per thread
|
128
|
+
tasks_per_thread: int = ceil(len(tasks) / max_threads)
|
129
|
+
|
130
|
+
# Split tasks into chunks
|
131
|
+
task_chunks: list[Sequence[Awaitable[T]]] = [
|
132
|
+
tasks[i : i + tasks_per_thread]
|
133
|
+
for i in range(0, len(tasks), tasks_per_thread)
|
134
|
+
]
|
135
|
+
|
136
|
+
self.app.logger.info(
|
137
|
+
f"Splitting {len(tasks)} tasks into {len(task_chunks)} chunks across {max_threads} threads"
|
138
|
+
)
|
139
|
+
|
140
|
+
# Use ThreadPoolExecutor to manage threads
|
141
|
+
with ThreadPoolExecutor(max_workers=max_threads) as executor:
|
142
|
+
# Submit each chunk of tasks to a thread
|
143
|
+
future_results: list[list[Result]] = list(
|
144
|
+
executor.map(
|
145
|
+
thread_worker,
|
146
|
+
task_chunks,
|
147
|
+
range(len(task_chunks)), # chunk_id
|
148
|
+
)
|
149
|
+
)
|
150
|
+
|
151
|
+
# Flatten the results from all threads
|
152
|
+
results: list[Result] = [item for sublist in future_results for item in sublist]
|
153
|
+
end_time = time.time()
|
154
|
+
duration = end_time - start_time
|
155
|
+
self.app.logger.info(
|
156
|
+
f"All threads completed. Total results: {len(results)}. Duration: {duration:.2f} seconds"
|
157
|
+
)
|
158
|
+
|
159
|
+
return results
|
@@ -0,0 +1,13 @@
|
|
1
|
+
import asyncio
|
2
|
+
from functools import wraps
|
3
|
+
from typing import Any, Callable, Coroutine, TypeVar
|
4
|
+
|
5
|
+
T = TypeVar("T")
|
6
|
+
|
7
|
+
|
8
|
+
def to_async(func: Callable[..., T]) -> Callable[..., Coroutine[Any, Any, T]]:
|
9
|
+
@wraps(func)
|
10
|
+
async def wrapper(*args, **kwargs) -> T:
|
11
|
+
return await asyncio.to_thread(func, *args, **kwargs)
|
12
|
+
|
13
|
+
return wrapper
|
@@ -0,0 +1,19 @@
|
|
1
|
+
import os
|
2
|
+
|
3
|
+
import unique_sdk
|
4
|
+
|
5
|
+
|
6
|
+
def init_sdk():
|
7
|
+
"""Initializes the SDK."""
|
8
|
+
unique_sdk.api_base = os.environ.get("API_BASE") # Required
|
9
|
+
|
10
|
+
# Optional if being run locally
|
11
|
+
unique_sdk.api_key = os.environ.get(
|
12
|
+
"API_KEY", "dummy"
|
13
|
+
) # Optional if being run locally
|
14
|
+
unique_sdk.app_id = os.environ.get(
|
15
|
+
"APP_ID", "dummy"
|
16
|
+
) # Optional if being run locally
|
17
|
+
endpoint_secret = os.environ.get("ENDPOINT_SECRET", None)
|
18
|
+
|
19
|
+
return endpoint_secret
|
@@ -0,0 +1,118 @@
|
|
1
|
+
from typing import Literal
|
2
|
+
|
3
|
+
import unique_sdk
|
4
|
+
from unique_sdk import Content
|
5
|
+
|
6
|
+
from unique_toolkit.chat.state import ChatState
|
7
|
+
from unique_toolkit.performance.async_wrapper import to_async
|
8
|
+
|
9
|
+
|
10
|
+
class SearchService:
|
11
|
+
def __init__(self, state: ChatState):
|
12
|
+
self.state = state
|
13
|
+
|
14
|
+
def search(
|
15
|
+
self,
|
16
|
+
*,
|
17
|
+
search_string: str,
|
18
|
+
search_type: Literal["VECTOR", "COMBINED"],
|
19
|
+
limit: int,
|
20
|
+
scope_ids: list[str] = [],
|
21
|
+
):
|
22
|
+
"""
|
23
|
+
Performs a synchronous search in the knowledge base.
|
24
|
+
|
25
|
+
Args:
|
26
|
+
search_string (str): The search string.
|
27
|
+
search_type (Literal["VECTOR", "COMBINED"]): The type of search to perform.
|
28
|
+
limit (int): The maximum number of results to return.
|
29
|
+
|
30
|
+
Returns:
|
31
|
+
The search results.
|
32
|
+
"""
|
33
|
+
return self._trigger_search(search_string, search_type, limit, scope_ids)
|
34
|
+
|
35
|
+
@to_async
|
36
|
+
def async_search(
|
37
|
+
self,
|
38
|
+
*,
|
39
|
+
search_string: str,
|
40
|
+
search_type: Literal["VECTOR", "COMBINED"],
|
41
|
+
limit: int,
|
42
|
+
scope_ids: list[str] = [],
|
43
|
+
):
|
44
|
+
"""
|
45
|
+
Performs an asynchronous search in the knowledge base.
|
46
|
+
|
47
|
+
Args:
|
48
|
+
search_string (str): The search string.
|
49
|
+
search_type (Literal["VECTOR", "COMBINED"]): The type of search to perform.
|
50
|
+
limit (int): The maximum number of results to return.
|
51
|
+
|
52
|
+
Returns:
|
53
|
+
The search results.
|
54
|
+
"""
|
55
|
+
return self._trigger_search(search_string, search_type, limit, scope_ids)
|
56
|
+
|
57
|
+
def _trigger_search(
|
58
|
+
self,
|
59
|
+
search_string: str,
|
60
|
+
search_type: Literal["VECTOR", "COMBINED"],
|
61
|
+
limit: int,
|
62
|
+
scope_ids: list[str] = [],
|
63
|
+
):
|
64
|
+
scope_ids = scope_ids or self.state.scope_ids or []
|
65
|
+
return unique_sdk.Search.create(
|
66
|
+
user_id=self.state.user_id,
|
67
|
+
company_id=self.state.company_id,
|
68
|
+
chatId=self.state.chat_id,
|
69
|
+
searchString=search_string,
|
70
|
+
searchType=search_type,
|
71
|
+
scopeIds=scope_ids,
|
72
|
+
limit=limit,
|
73
|
+
chatOnly=self.state.chat_only,
|
74
|
+
)
|
75
|
+
|
76
|
+
def search_content(
|
77
|
+
self,
|
78
|
+
*,
|
79
|
+
where: dict,
|
80
|
+
) -> list[Content]:
|
81
|
+
"""
|
82
|
+
Performs a search in the knowledge base by filter.
|
83
|
+
|
84
|
+
Args:
|
85
|
+
where (dict): The search criteria.
|
86
|
+
|
87
|
+
Returns:
|
88
|
+
The search results.
|
89
|
+
"""
|
90
|
+
return self._trigger_search_content(where)
|
91
|
+
|
92
|
+
@to_async
|
93
|
+
def async_search_content(
|
94
|
+
self,
|
95
|
+
*,
|
96
|
+
where: dict,
|
97
|
+
) -> list[Content]:
|
98
|
+
"""
|
99
|
+
Performs an asynchronous search in the knowledge base by filter.
|
100
|
+
|
101
|
+
Args:
|
102
|
+
where (dict): The search criteria.
|
103
|
+
|
104
|
+
Returns:
|
105
|
+
The search results.
|
106
|
+
"""
|
107
|
+
return self._trigger_search_content(where)
|
108
|
+
|
109
|
+
def _trigger_search_content(
|
110
|
+
self,
|
111
|
+
where: dict,
|
112
|
+
) -> list[Content]:
|
113
|
+
return unique_sdk.Content.search(
|
114
|
+
user_id=self.state.user_id,
|
115
|
+
company_id=self.state.company_id,
|
116
|
+
chatId=self.state.chat_id,
|
117
|
+
where=where, # type: ignore
|
118
|
+
)
|
@@ -0,0 +1,29 @@
|
|
1
|
+
from http import HTTPStatus
|
2
|
+
|
3
|
+
import unique_sdk
|
4
|
+
|
5
|
+
from unique_toolkit.event.schema import Event
|
6
|
+
|
7
|
+
|
8
|
+
def verify_signature_and_construct_event(
|
9
|
+
headers, payload: bytes, endpoint_secret: str, logger
|
10
|
+
) -> Event:
|
11
|
+
sig_header = headers.get("X-Unique-Signature")
|
12
|
+
timestamp = headers.get("X-Unique-Created-At")
|
13
|
+
|
14
|
+
if not sig_header or not timestamp:
|
15
|
+
logger.error("⚠️ Webhook signature or timestamp headers missing.")
|
16
|
+
return False, HTTPStatus.BAD_REQUEST
|
17
|
+
|
18
|
+
try:
|
19
|
+
event = unique_sdk.Webhook.construct_event(
|
20
|
+
payload,
|
21
|
+
sig_header,
|
22
|
+
timestamp,
|
23
|
+
endpoint_secret,
|
24
|
+
)
|
25
|
+
logger.info("✅ Webhook signature verification successful.")
|
26
|
+
return Event(**event)
|
27
|
+
except unique_sdk.SignatureVerificationError as e:
|
28
|
+
logger.error("⚠️ Webhook signature verification failed. " + str(e))
|
29
|
+
return False, HTTPStatus.BAD_REQUEST
|
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2023 Unique-AG
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
@@ -0,0 +1,33 @@
|
|
1
|
+
Metadata-Version: 2.1
|
2
|
+
Name: unique_toolkit
|
3
|
+
Version: 0.0.1
|
4
|
+
Summary:
|
5
|
+
License: MIT
|
6
|
+
Author: Unique Data Science
|
7
|
+
Author-email: datascience@unique.ch
|
8
|
+
Requires-Python: >=3.11,<4.0
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
13
|
+
Requires-Dist: pydantic (>=2.8.2,<3.0.0)
|
14
|
+
Requires-Dist: pyhumps (>=3.8.0,<4.0.0)
|
15
|
+
Requires-Dist: typing-extensions (>=4.9.0,<5.0.0)
|
16
|
+
Requires-Dist: unique-sdk (>=0.8.10,<0.9.0)
|
17
|
+
Description-Content-Type: text/markdown
|
18
|
+
|
19
|
+
# Unique Tool Kit
|
20
|
+
|
21
|
+
This package provides highlevel abstarctions and methods on top of `unique_sdk` to ease application development for the Unique Platform.
|
22
|
+
|
23
|
+
|
24
|
+
# Development instructions
|
25
|
+
|
26
|
+
1. Install poetry on your system (through `brew` or `pipx`).
|
27
|
+
|
28
|
+
2. Install `pyenv` and install python 3.11. `pyenv` is recommended as otherwise poetry uses the python version used to install itself and not the user preferred python version.
|
29
|
+
|
30
|
+
3. If you then run `python --version` in your terminal, you should be able to see python version as specified in `.python-version`.
|
31
|
+
|
32
|
+
4. Then finally run `poetry install` to install the package and all dependencies.
|
33
|
+
|
@@ -0,0 +1,23 @@
|
|
1
|
+
unique_toolkit/__init__.py,sha256=vObIHz3Vte5K6tlrytiNQQ1uqhibwnhSzB5WIt9cyIs,197
|
2
|
+
unique_toolkit/chat/__init__.py,sha256=TocIEJrMO5_xJB4ehFhslqoxOsV2oiLlglUdIWVLa-s,166
|
3
|
+
unique_toolkit/chat/messages.py,sha256=IuYPz8PoK6HyFsYigtfYkfz2wB8zhDJyjryWYC2D7k4,416
|
4
|
+
unique_toolkit/chat/service.py,sha256=UigC5-1wdQYoWLfzAU7h8hl_SB05YSCOTrxhU7dgMG4,5511
|
5
|
+
unique_toolkit/chat/state.py,sha256=7N9vsMl4mu1mbtFqg61T6-42UgnkXmLwhVfIwVnm6tc,1327
|
6
|
+
unique_toolkit/event/__init__.py,sha256=fqfQeanYjxLFGsrX97Ti3HpzLRCE3kDUlMBVXEvzr0s,80
|
7
|
+
unique_toolkit/event/constants.py,sha256=hwdCgSKBAZQYZSl1nFkP1yWiw6GBI7KbcNJrdxFxVm8,66
|
8
|
+
unique_toolkit/event/schema.py,sha256=sT0gtJN2LT0ormP7HfVot11Oub1YlzfBCKFj3G0Or4w,879
|
9
|
+
unique_toolkit/llm/__init__.py,sha256=3hT7PhdHnEQaqILJ6gGSe0cpkWEDMgZ_PHw1Q01W0e4,127
|
10
|
+
unique_toolkit/llm/json_parser.py,sha256=sHTe6_VuqqwrC1xCg9ud3VOukEzVSoj0Uce_sv7GEYE,528
|
11
|
+
unique_toolkit/llm/models.py,sha256=Gt691uSL74e3bdIm4UhAcp3Nw-jafnqJSgKdzhLJKww,1254
|
12
|
+
unique_toolkit/llm/service.py,sha256=UXXMo7MKcj7fbDKlv0gk2J00ClL1F7n0_6uiwOLSXoY,5226
|
13
|
+
unique_toolkit/observability/log_config.py,sha256=mfIu4cX95AiqOCdh2iW3iLoXtwrIrkPCXRtG_zYMhSs,679
|
14
|
+
unique_toolkit/performance/__init__.py,sha256=TYPrsYdxXhhom12ub0dzvR6dHtFdjnF-P506LYitj8Y,50
|
15
|
+
unique_toolkit/performance/async_executor.py,sha256=8N8GQn-MQN-8fX8DAbbYt7s2_91G0nchFQ25i9lIxsE,5555
|
16
|
+
unique_toolkit/performance/async_wrapper.py,sha256=MB3cgqcl-Y7Ltc9a_l8bkhfOGrds1lUtici17b9dGuQ,339
|
17
|
+
unique_toolkit/sdk/init.py,sha256=UQLbHxUjN_dttkiUznfJclYPHNVwANdf8w6H4SKs9Y4,482
|
18
|
+
unique_toolkit/search/service.py,sha256=hGzfFAdBt9Y5pwPHXTEmzid4FnEsXu9DiSzpw5gPv1Y,3167
|
19
|
+
unique_toolkit/security/verify.py,sha256=RxBw-gnWUsvqZT6rAt5Eq0vaR0rDY8AqUOTBZmQUItE,927
|
20
|
+
unique_toolkit-0.0.1.dist-info/LICENSE,sha256=nojs8Z8soUMb9wg1sAUZdJxyhzRGrUIXFeq0_I-zejo,1065
|
21
|
+
unique_toolkit-0.0.1.dist-info/METADATA,sha256=klnJs0EqSKVW0wz384xzI0PU-gaAbTIkb0EVHpLukhQ,1233
|
22
|
+
unique_toolkit-0.0.1.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
23
|
+
unique_toolkit-0.0.1.dist-info/RECORD,,
|