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.
@@ -0,0 +1,3 @@
1
+ from .observability.log_config import load_logging_config # noqa: F401
2
+ from .sdk.init import init_sdk # noqa: F401
3
+ from .security.verify import verify_signature_and_construct_event # noqa: F401
@@ -0,0 +1,3 @@
1
+ from .messages import AIMessage, AIMessageList, AIMessageRole # noqa: F401
2
+ from .service import ChatService # noqa: F401
3
+ from .state import ChatState # noqa: F401
@@ -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,2 @@
1
+ from .models import LLMModelInfo, LLMModelName, get_llm_model_info # noqa: F401
2
+ from .service import LLMService # noqa: F401
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 1.9.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any