unique_toolkit 0.0.2__py3-none-any.whl → 0.5.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,380 @@
1
+ import logging
2
+ import re
3
+ from typing import Optional
4
+
5
+ import unique_sdk
6
+
7
+ from unique_toolkit.app.performance.async_wrapper import async_warning, to_async
8
+ from unique_toolkit.chat.schemas import ChatMessage, ChatMessageRole
9
+ from unique_toolkit.chat.state import ChatState
10
+ from unique_toolkit.content.schemas import ContentReference
11
+ from unique_toolkit.content.utils import count_tokens
12
+
13
+
14
+ class ChatService:
15
+ """
16
+ Provides all functionalities to manage the chat session.
17
+
18
+ Attributes:
19
+ state (ChatState): The chat state.
20
+ logger (Optional[logging.Logger]): The logger. Defaults to None.
21
+ """
22
+
23
+ def __init__(self, state: ChatState, logger: Optional[logging.Logger] = None):
24
+ self.state = state
25
+ self.logger = logger or logging.getLogger(__name__)
26
+
27
+ def modify_assistant_message(
28
+ self,
29
+ content: str,
30
+ references: list[ContentReference] = [],
31
+ debug_info: dict = {},
32
+ message_id: Optional[str] = None,
33
+ ) -> ChatMessage:
34
+ """
35
+ Modifies a message in the chat session synchronously.
36
+
37
+ Args:
38
+ content (str): The new content for the message.
39
+ references (list[ContentReference]): list of ContentReference objects. Defaults to [].
40
+ debug_info (dict[str, Any]]]): Debug information. Defaults to {}.
41
+ message_id (Optional[str]): The message ID. Defaults to None.
42
+
43
+ Returns:
44
+ ChatMessage: The modified message.
45
+
46
+ Raises:
47
+ Exception: If the modification fails.
48
+ """
49
+ return self._trigger_modify_assistant_message(
50
+ content=content,
51
+ message_id=message_id,
52
+ references=references,
53
+ debug_info=debug_info,
54
+ )
55
+
56
+ @to_async
57
+ @async_warning
58
+ def async_modify_assistant_message(
59
+ self,
60
+ content: str,
61
+ references: list[ContentReference] = [],
62
+ debug_info: dict = {},
63
+ message_id: Optional[str] = None,
64
+ ) -> ChatMessage:
65
+ """
66
+ Modifies a message in the chat session asynchronously.
67
+
68
+ Args:
69
+ content (str): The new content for the message.
70
+ message_id (str, optional): The message ID. Defaults to None, then the ChatState assistant message id is used.
71
+ references (list[ContentReference]): list of ContentReference objects. Defaults to None.
72
+ debug_info (Optional[dict[str, Any]]], optional): Debug information. Defaults to None.
73
+
74
+ Returns:
75
+ ChatMessage: The modified message.
76
+
77
+ Raises:
78
+ Exception: If the modification fails.
79
+ """
80
+ return self._trigger_modify_assistant_message(
81
+ content,
82
+ message_id,
83
+ references,
84
+ debug_info,
85
+ )
86
+
87
+ def get_full_history(self) -> list[ChatMessage]:
88
+ """
89
+ Loads the full chat history for the chat session synchronously.
90
+
91
+ Returns:
92
+ list[ChatMessage]: The full chat history.
93
+
94
+ Raises:
95
+ Exception: If the loading fails.
96
+ """
97
+ return self._get_full_history()
98
+
99
+ @to_async
100
+ @async_warning
101
+ def async_get_full_history(self) -> list[ChatMessage]:
102
+ """
103
+ Loads the full chat history for the chat session asynchronously.
104
+
105
+ Returns:
106
+ list[ChatMessage]: The full chat history.
107
+
108
+ Raises:
109
+ Exception: If the loading fails.
110
+ """
111
+ return self._get_full_history()
112
+
113
+ def get_full_and_selected_history(
114
+ self,
115
+ token_limit: int,
116
+ percent_of_max_tokens: float,
117
+ max_messages: int,
118
+ ) -> tuple[list[ChatMessage], list[ChatMessage]]:
119
+ """
120
+ Loads the chat history for the chat session synchronously.
121
+
122
+ Args:
123
+ token_limit (int): The maximum number of tokens to load.
124
+ percent_of_max_tokens (float): The percentage of the maximum tokens to load.
125
+ max_messages (int): The maximum number of messages to load.
126
+
127
+ Returns:
128
+ tuple[list[ChatMessage], list[ChatMessage]]: The selected and full chat history.
129
+
130
+ Raises:
131
+ Exception: If the loading fails.
132
+ """
133
+ return self._get_full_and_selected_history(
134
+ token_limit=token_limit,
135
+ percent_of_max_tokens=percent_of_max_tokens,
136
+ max_messages=max_messages,
137
+ )
138
+
139
+ @to_async
140
+ @async_warning
141
+ def async_get_full_and_selected_history(
142
+ self,
143
+ token_limit: int,
144
+ percent_of_max_tokens: float,
145
+ max_messages: int,
146
+ ) -> tuple[list[ChatMessage], list[ChatMessage]]:
147
+ """
148
+ Loads the chat history for the chat session asynchronously.
149
+
150
+ Args:
151
+ token_limit (int): The maximum number of tokens to load.
152
+ percent_of_max_tokens (float): The percentage of the maximum tokens to load.
153
+ max_messages (int): The maximum number of messages to load.
154
+
155
+ Returns:
156
+ tuple[list[ChatMessage], list[ChatMessage]]: The selected and full chat history.
157
+
158
+ Raises:
159
+ Exception: If the loading fails.
160
+ """
161
+ return self._get_full_and_selected_history(
162
+ token_limit=token_limit,
163
+ percent_of_max_tokens=percent_of_max_tokens,
164
+ max_messages=max_messages,
165
+ )
166
+
167
+ def create_assistant_message(
168
+ self,
169
+ content: str,
170
+ references: list[ContentReference] = [],
171
+ debug_info: dict = {},
172
+ ):
173
+ """
174
+ Creates a message in the chat session synchronously.
175
+
176
+ Args:
177
+ content (str): The content for the message.
178
+ references (list[ContentReference]): list of ContentReference objects. Defaults to None.
179
+ debug_info (dict[str, Any]]): Debug information. Defaults to None.
180
+
181
+ Returns:
182
+ ChatMessage: The created message.
183
+
184
+ Raises:
185
+ Exception: If the creation fails.
186
+ """
187
+ return self._trigger_create_assistant_message(
188
+ content=content,
189
+ references=references,
190
+ debug_info=debug_info,
191
+ )
192
+
193
+ @to_async
194
+ @async_warning
195
+ def async_create_assistant_message(
196
+ self,
197
+ content: str,
198
+ references: list[ContentReference] = [],
199
+ debug_info: dict = {},
200
+ ):
201
+ """
202
+ Creates a message in the chat session asynchronously.
203
+
204
+ Args:
205
+ content (str): The content for the message.
206
+ references (list[ContentReference]): list of references. Defaults to None.
207
+ debug_info (dict[str, Any]]): Debug information. Defaults to None.
208
+
209
+ Returns:
210
+ ChatMessage: The created message.
211
+
212
+ Raises:
213
+ Exception: If the creation fails.
214
+ """
215
+
216
+ return self._trigger_create_assistant_message(
217
+ content=content,
218
+ references=references,
219
+ debug_info=debug_info,
220
+ )
221
+
222
+ def _trigger_modify_assistant_message(
223
+ self,
224
+ content: str,
225
+ message_id: Optional[str],
226
+ references: list[ContentReference],
227
+ debug_info: dict,
228
+ ) -> ChatMessage:
229
+ message_id = message_id or self.state.assistant_message_id
230
+
231
+ try:
232
+ message = unique_sdk.Message.modify(
233
+ user_id=self.state.user_id,
234
+ company_id=self.state.company_id,
235
+ id=message_id, # type: ignore
236
+ chatId=self.state.chat_id,
237
+ text=content,
238
+ references=self._map_references(references), # type: ignore
239
+ debugInfo=debug_info or {},
240
+ )
241
+ except Exception as e:
242
+ self.logger.error(f"Failed to modify assistant message: {e}")
243
+ raise e
244
+ return ChatMessage(**message)
245
+
246
+ def _trigger_create_assistant_message(
247
+ self,
248
+ content: str,
249
+ references: list[ContentReference],
250
+ debug_info: dict,
251
+ ) -> ChatMessage:
252
+ try:
253
+ message = unique_sdk.Message.create(
254
+ user_id=self.state.user_id,
255
+ company_id=self.state.company_id,
256
+ chatId=self.state.chat_id,
257
+ assistantId=self.state.assistant_id,
258
+ text=content,
259
+ role=ChatMessageRole.ASSISTANT.name,
260
+ references=self._map_references(references), # type: ignore
261
+ debugInfo=debug_info,
262
+ )
263
+ except Exception as e:
264
+ self.logger.error(f"Failed to create assistant message: {e}")
265
+ raise e
266
+ return ChatMessage(**message)
267
+
268
+ @staticmethod
269
+ def _map_references(references: list[ContentReference]):
270
+ return [
271
+ {
272
+ "name": ref.name,
273
+ "url": ref.url,
274
+ "sequenceNumber": ref.sequence_number,
275
+ "sourceId": ref.source_id,
276
+ "source": ref.source,
277
+ }
278
+ for ref in references
279
+ ]
280
+
281
+ def _get_full_and_selected_history(
282
+ self,
283
+ token_limit,
284
+ percent_of_max_tokens=0.15,
285
+ max_messages=4,
286
+ ):
287
+ full_history = self._get_full_history()
288
+ selected_history = self._get_selection_from_history(
289
+ full_history,
290
+ int(round(token_limit * percent_of_max_tokens)),
291
+ max_messages,
292
+ )
293
+
294
+ return full_history, selected_history
295
+
296
+ def _get_full_history(self):
297
+ SYSTEM_MESSAGE_PREFIX = "[SYSTEM] "
298
+
299
+ messages = self._trigger_list_messages(self.state.chat_id)
300
+
301
+ # Remove the last two messages
302
+ messages = messages["data"][:-2] # type: ignore
303
+ filtered_messages = []
304
+ for message in messages:
305
+ if message["text"] is None:
306
+ continue
307
+ elif SYSTEM_MESSAGE_PREFIX in message["text"]:
308
+ continue
309
+ else:
310
+ filtered_messages.append(message)
311
+
312
+ return self._map_to_chat_messages(filtered_messages)
313
+
314
+ def _trigger_list_messages(self, chat_id: str):
315
+ try:
316
+ messages = unique_sdk.Message.list(
317
+ user_id=self.state.user_id,
318
+ company_id=self.state.company_id,
319
+ chatId=chat_id,
320
+ )
321
+ return messages
322
+ except Exception as e:
323
+ self.logger.error(f"Failed to list chat history: {e}")
324
+ raise e
325
+
326
+ @staticmethod
327
+ def _map_to_chat_messages(messages: list[dict]):
328
+ return [ChatMessage(**msg) for msg in messages]
329
+
330
+ def _get_selection_from_history(
331
+ self,
332
+ full_history: list[ChatMessage],
333
+ max_tokens: int,
334
+ max_messages=4,
335
+ ):
336
+ messages = full_history[-max_messages:]
337
+ filtered_messages = [m for m in messages if m.content]
338
+ mapped_messages = []
339
+
340
+ for m in filtered_messages:
341
+ m.content = re.sub(r"<sup>\d+</sup>", "", m.content)
342
+ m.role = (
343
+ ChatMessageRole.ASSISTANT
344
+ if m.role == ChatMessageRole.ASSISTANT
345
+ else ChatMessageRole.USER
346
+ )
347
+ mapped_messages.append(m)
348
+
349
+ return self._pick_messages_in_reverse_for_token_window(
350
+ messages=mapped_messages,
351
+ limit=max_tokens,
352
+ )
353
+
354
+ def _pick_messages_in_reverse_for_token_window(
355
+ self,
356
+ messages: list[ChatMessage],
357
+ limit: int,
358
+ ) -> list[ChatMessage]:
359
+ if len(messages) < 1 or limit < 1:
360
+ return []
361
+
362
+ last_index = len(messages) - 1
363
+ token_count = count_tokens(messages[last_index].content)
364
+ while token_count > limit:
365
+ self.logger.debug(
366
+ f"Limit too low for the initial message. Last message TokenCount {token_count} available tokens {limit} - cutting message in half until it fits"
367
+ )
368
+ content = messages[last_index].content
369
+ messages[last_index].content = content[: len(content) // 2] + "..."
370
+ token_count = count_tokens(messages[last_index].content)
371
+
372
+ while token_count <= limit and last_index > 0:
373
+ token_count = count_tokens(
374
+ "".join([msg.content for msg in messages[:last_index]])
375
+ )
376
+ if token_count <= limit:
377
+ last_index -= 1
378
+
379
+ last_index = max(0, last_index)
380
+ return messages[last_index:]
@@ -0,0 +1,60 @@
1
+ from dataclasses import dataclass
2
+ from typing import Self
3
+
4
+ from unique_toolkit.app.schemas 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
+ scope_ids (list[str] | None): The scope IDs.
17
+ chat_only (bool): The chat only flag.
18
+ user_message_text (str): The user message text.
19
+ user_message_id (str): The user message ID.
20
+ assistant_message_id (str): The assistant message ID.
21
+ """
22
+
23
+ company_id: str
24
+ user_id: str
25
+ assistant_id: str
26
+ chat_id: str
27
+ scope_ids: list[str] | None = None
28
+ chat_only: bool = False
29
+ user_message_text: str | None = None
30
+ user_message_id: str | None = None
31
+ assistant_message_id: str | None = None
32
+ module_name: str | None = None
33
+
34
+ @classmethod
35
+ def from_event(cls, event: Event) -> Self:
36
+ """
37
+ Creates a ChatState instance from the Event.
38
+
39
+ Args:
40
+ event (Event): The Event object.
41
+
42
+ Returns:
43
+ ChatManager: The ChatManager instance.
44
+ """
45
+ config = event.payload.configuration
46
+
47
+ scope_ids = config.get("scopeIds") or None
48
+ chat_only = config.get("scopeToChatOnUpload", False)
49
+ return cls(
50
+ user_id=event.user_id,
51
+ chat_id=event.payload.chat_id,
52
+ company_id=event.company_id,
53
+ assistant_id=event.payload.assistant_id,
54
+ scope_ids=scope_ids,
55
+ chat_only=chat_only,
56
+ user_message_text=event.payload.user_message.text,
57
+ user_message_id=event.payload.user_message.id,
58
+ assistant_message_id=event.payload.assistant_message.id,
59
+ module_name=event.payload.name,
60
+ )
@@ -0,0 +1,25 @@
1
+ from unique_toolkit.chat.schemas import ChatMessage
2
+ from unique_toolkit.content.utils import count_tokens
3
+
4
+
5
+ def convert_chat_history_to_injectable_string(
6
+ history: list[ChatMessage],
7
+ ) -> tuple[list[str], int]:
8
+ """
9
+ Converts chat history to a string that can be injected into the model.
10
+
11
+ Args:
12
+ history (list[ChatMessage]): The chat history.
13
+
14
+ Returns:
15
+ tuple[list[str], int]: The chat history and the token length of the chat context.
16
+ """
17
+ chatHistory = []
18
+ for msg in history:
19
+ if msg.role.value == "assistant":
20
+ chatHistory.append(f"previous_answer: {msg.content}")
21
+ else:
22
+ chatHistory.append(f"previous_question: {msg.content}")
23
+ chatContext = "\n".join(chatHistory)
24
+ chatContextTokenLength = count_tokens(chatContext)
25
+ return chatHistory, chatContextTokenLength
@@ -0,0 +1,90 @@
1
+ from datetime import datetime
2
+ from enum import StrEnum
3
+ from typing import Optional
4
+
5
+ from humps import camelize
6
+ from pydantic import BaseModel, ConfigDict
7
+
8
+ # set config to convert camelCase to snake_case
9
+ model_config = ConfigDict(
10
+ alias_generator=camelize,
11
+ populate_by_name=True,
12
+ arbitrary_types_allowed=True,
13
+ )
14
+
15
+
16
+ class ContentMetadata(BaseModel):
17
+ model_config = model_config
18
+ key: str
19
+ mime_type: str
20
+
21
+
22
+ class ContentChunk(BaseModel):
23
+ model_config = model_config
24
+ id: str
25
+ text: str
26
+ order: int
27
+ key: str | None = None
28
+ chunk_id: str | None = None
29
+ url: str | None = None
30
+ title: str | None = None
31
+ start_page: int | None = None
32
+ end_page: int | None = None
33
+
34
+ object: str | None = None
35
+ metadata: ContentMetadata | None = None
36
+ internally_stored_at: datetime | None = None
37
+ created_at: datetime | None = None
38
+ updated_at: datetime | None = None
39
+
40
+
41
+ class Content(BaseModel):
42
+ model_config = model_config
43
+ id: str
44
+ key: str
45
+ title: str | None = None
46
+ url: str | None = None
47
+ chunks: list[ContentChunk] = []
48
+ write_url: str | None = None
49
+ read_url: str | None = None
50
+
51
+
52
+ class ContentReference(BaseModel):
53
+ model_config = model_config
54
+ id: str
55
+ message_id: str
56
+ name: str
57
+ sequence_number: int
58
+ source: str
59
+ source_id: str
60
+ url: str
61
+
62
+
63
+ class ContentSearchType(StrEnum):
64
+ COMBINED = "COMBINED"
65
+ VECTOR = "VECTOR"
66
+
67
+
68
+ class ContentSearchResult(BaseModel):
69
+ """Schema corresponding to unique_sdk.SearchResult"""
70
+
71
+ id: str
72
+ text: str
73
+ order: int
74
+ chunkId: str | None = None
75
+ key: str | None = None
76
+ title: str | None = None
77
+ url: str | None = None
78
+ startPage: int | None = None
79
+ endPage: int | None = None
80
+ object: str | None = None
81
+
82
+
83
+ class ContentUploadInput(BaseModel):
84
+ key: str
85
+ title: str
86
+ mime_type: str
87
+
88
+ owner_type: Optional[str] = None
89
+ owner_id: Optional[str] = None
90
+ byte_size: Optional[int] = None