unique_toolkit 0.7.26__py3-none-any.whl → 0.7.28__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.
@@ -1,4 +1,6 @@
1
+ import json
1
2
  from enum import StrEnum
3
+ from pathlib import Path
2
4
  from typing import Any, Optional
3
5
 
4
6
  from humps import camelize
@@ -27,6 +29,14 @@ class BaseEvent(BaseModel):
27
29
  user_id: str
28
30
  company_id: str
29
31
 
32
+ @classmethod
33
+ def from_json_file(cls, file_path: Path) -> "BaseEvent":
34
+ if not file_path.exists():
35
+ raise FileNotFoundError(f"File not found: {file_path}")
36
+ with file_path.open("r", encoding="utf-8") as f:
37
+ data = json.load(f)
38
+ return cls.model_validate(data)
39
+
30
40
 
31
41
  ###
32
42
  # ChatEvent schemas
@@ -110,8 +120,9 @@ class ChatEventPayload(BaseModel):
110
120
  default_factory=dict,
111
121
  description="Parameters extracted from module selection function calling the tool.",
112
122
  )
113
- metadata_filter: dict[str, Any] = Field(
114
- default_factory=dict,
123
+ # Default is None as empty dict triggers error in `backend-ingestion`
124
+ metadata_filter: dict[str, Any] | None = Field(
125
+ default=None,
115
126
  description="Metadata filter compiled after module selection function calling and scope rules.",
116
127
  )
117
128
  raw_scope_rules: UniqueQL | None = Field(
@@ -140,6 +151,14 @@ class ChatEvent(BaseEvent):
140
151
  created_at: Optional[int] = None
141
152
  version: Optional[str] = None
142
153
 
154
+ @classmethod
155
+ def from_json_file(cls, file_path: Path) -> "ChatEvent":
156
+ if not file_path.exists():
157
+ raise FileNotFoundError(f"File not found: {file_path}")
158
+ with file_path.open("r", encoding="utf-8") as f:
159
+ data = json.load(f)
160
+ return cls.model_validate(data)
161
+
143
162
 
144
163
  @deprecated(
145
164
  """Use the more specific `ChatEvent` instead that has the same properties. \
@@ -150,3 +169,11 @@ class Event(ChatEvent):
150
169
  # The below should only affect type hints
151
170
  # event: EventName T
152
171
  # payload: EventPayload
172
+
173
+ @classmethod
174
+ def from_json_file(cls, file_path: Path) -> "Event":
175
+ if not file_path.exists():
176
+ raise FileNotFoundError(f"File not found: {file_path}")
177
+ with file_path.open("r", encoding="utf-8") as f:
178
+ data = json.load(f)
179
+ return cls.model_validate(data)
@@ -3,6 +3,7 @@ import re
3
3
  from typing import Any, Dict, List, cast
4
4
 
5
5
  import unique_sdk
6
+ from typing_extensions import deprecated
6
7
  from unique_sdk._list_object import ListObject
7
8
 
8
9
  from unique_toolkit._common import _time_utils
@@ -673,6 +674,7 @@ async def modify_message_assessment_async(
673
674
  raise e
674
675
 
675
676
 
677
+ @deprecated("Use stream_complete_with_references instead")
676
678
  def stream_complete_to_chat(
677
679
  company_id: str,
678
680
  user_id: str,
@@ -682,7 +684,43 @@ def stream_complete_to_chat(
682
684
  assistant_id: str,
683
685
  messages: LanguageModelMessages,
684
686
  model_name: LanguageModelName | str,
685
- content_chunks: list[ContentChunk] = [],
687
+ content_chunks: list[ContentChunk] | None = None,
688
+ debug_info: dict = {},
689
+ temperature: float = DEFAULT_COMPLETE_TEMPERATURE,
690
+ timeout: int = DEFAULT_COMPLETE_TIMEOUT,
691
+ tools: list[LanguageModelTool | LanguageModelToolDescription] | None = None,
692
+ start_text: str | None = None,
693
+ other_options: dict | None = None,
694
+ ) -> LanguageModelStreamResponse:
695
+ return stream_complete_with_references(
696
+ company_id=company_id,
697
+ user_id=user_id,
698
+ assistant_message_id=assistant_message_id,
699
+ user_message_id=user_message_id,
700
+ chat_id=chat_id,
701
+ assistant_id=assistant_id,
702
+ messages=messages,
703
+ model_name=model_name,
704
+ content_chunks=content_chunks,
705
+ debug_info=debug_info,
706
+ temperature=temperature,
707
+ timeout=timeout,
708
+ tools=tools,
709
+ start_text=start_text,
710
+ other_options=other_options,
711
+ )
712
+
713
+
714
+ def stream_complete_with_references(
715
+ company_id: str,
716
+ user_id: str,
717
+ assistant_message_id: str,
718
+ user_message_id: str,
719
+ chat_id: str,
720
+ assistant_id: str,
721
+ messages: LanguageModelMessages,
722
+ model_name: LanguageModelName | str,
723
+ content_chunks: list[ContentChunk] | None = None,
686
724
  debug_info: dict = {},
687
725
  temperature: float = DEFAULT_COMPLETE_TEMPERATURE,
688
726
  timeout: int = DEFAULT_COMPLETE_TIMEOUT,
@@ -719,7 +757,7 @@ def stream_complete_to_chat(
719
757
  temperature=temperature,
720
758
  tools=tools,
721
759
  other_options=other_options,
722
- content_chunks=content_chunks,
760
+ content_chunks=content_chunks or [],
723
761
  )
724
762
 
725
763
  try:
@@ -747,6 +785,7 @@ def stream_complete_to_chat(
747
785
  raise e
748
786
 
749
787
 
788
+ @deprecated("Use stream_complete_with_references_async instead")
750
789
  async def stream_complete_to_chat_async(
751
790
  company_id: str,
752
791
  user_id: str,
@@ -756,7 +795,43 @@ async def stream_complete_to_chat_async(
756
795
  assistant_id: str,
757
796
  messages: LanguageModelMessages,
758
797
  model_name: LanguageModelName | str,
759
- content_chunks: list[ContentChunk] = [],
798
+ content_chunks: list[ContentChunk] | None = None,
799
+ debug_info: dict = {},
800
+ temperature: float = DEFAULT_COMPLETE_TEMPERATURE,
801
+ timeout: int = DEFAULT_COMPLETE_TIMEOUT,
802
+ tools: list[LanguageModelTool | LanguageModelToolDescription] | None = None,
803
+ start_text: str | None = None,
804
+ other_options: dict | None = None,
805
+ ) -> LanguageModelStreamResponse:
806
+ return await stream_complete_with_references_async(
807
+ company_id=company_id,
808
+ user_id=user_id,
809
+ assistant_message_id=assistant_message_id,
810
+ user_message_id=user_message_id,
811
+ chat_id=chat_id,
812
+ assistant_id=assistant_id,
813
+ messages=messages,
814
+ model_name=model_name,
815
+ content_chunks=content_chunks,
816
+ debug_info=debug_info,
817
+ temperature=temperature,
818
+ timeout=timeout,
819
+ tools=tools,
820
+ start_text=start_text,
821
+ other_options=other_options,
822
+ )
823
+
824
+
825
+ async def stream_complete_with_references_async(
826
+ company_id: str,
827
+ user_id: str,
828
+ assistant_message_id: str,
829
+ user_message_id: str,
830
+ chat_id: str,
831
+ assistant_id: str,
832
+ messages: LanguageModelMessages,
833
+ model_name: LanguageModelName | str,
834
+ content_chunks: list[ContentChunk] | None = None,
760
835
  debug_info: dict = {},
761
836
  temperature: float = DEFAULT_COMPLETE_TEMPERATURE,
762
837
  timeout: int = DEFAULT_COMPLETE_TIMEOUT,
@@ -778,7 +853,7 @@ async def stream_complete_to_chat_async(
778
853
  temperature=temperature,
779
854
  tools=tools,
780
855
  other_options=other_options,
781
- content_chunks=content_chunks,
856
+ content_chunks=content_chunks or [],
782
857
  )
783
858
 
784
859
  try:
@@ -48,8 +48,8 @@ from unique_toolkit.language_model.schemas import (
48
48
  )
49
49
 
50
50
  from .functions import (
51
- stream_complete_to_chat,
52
- stream_complete_to_chat_async,
51
+ stream_complete_with_references,
52
+ stream_complete_with_references_async,
53
53
  )
54
54
 
55
55
  logger = logging.getLogger(f"toolkit.{DOMAIN_NAME}.{__name__}")
@@ -1084,11 +1084,36 @@ class ChatService:
1084
1084
  label=label,
1085
1085
  )
1086
1086
 
1087
+ @deprecated("Use complete_with_references instead")
1087
1088
  def stream_complete(
1088
1089
  self,
1089
1090
  messages: LanguageModelMessages,
1090
1091
  model_name: LanguageModelName | str,
1091
- content_chunks: list[ContentChunk] = [],
1092
+ content_chunks: list[ContentChunk] | None = None,
1093
+ debug_info: dict = {},
1094
+ temperature: float = DEFAULT_COMPLETE_TEMPERATURE,
1095
+ timeout: int = DEFAULT_COMPLETE_TIMEOUT,
1096
+ tools: Optional[list[LanguageModelTool | LanguageModelToolDescription]] = None,
1097
+ start_text: Optional[str] = None,
1098
+ other_options: Optional[dict] = None,
1099
+ ) -> LanguageModelStreamResponse:
1100
+ return self.complete_with_references(
1101
+ messages=messages,
1102
+ model_name=model_name,
1103
+ content_chunks=content_chunks,
1104
+ debug_info=debug_info,
1105
+ temperature=temperature,
1106
+ timeout=timeout,
1107
+ tools=tools,
1108
+ start_text=start_text,
1109
+ other_options=other_options,
1110
+ )
1111
+
1112
+ def complete_with_references(
1113
+ self,
1114
+ messages: LanguageModelMessages,
1115
+ model_name: LanguageModelName | str,
1116
+ content_chunks: list[ContentChunk] | None = None,
1092
1117
  debug_info: dict = {},
1093
1118
  temperature: float = DEFAULT_COMPLETE_TEMPERATURE,
1094
1119
  timeout: int = DEFAULT_COMPLETE_TIMEOUT,
@@ -1117,7 +1142,7 @@ class ChatService:
1117
1142
  ]
1118
1143
  )
1119
1144
 
1120
- return stream_complete_to_chat(
1145
+ return stream_complete_with_references(
1121
1146
  company_id=company_id,
1122
1147
  user_id=user_id,
1123
1148
  assistant_message_id=assistant_message_id,
@@ -1139,7 +1164,7 @@ class ChatService:
1139
1164
  self,
1140
1165
  messages: LanguageModelMessages,
1141
1166
  model_name: LanguageModelName | str,
1142
- content_chunks: list[ContentChunk] = [],
1167
+ content_chunks: list[ContentChunk] | None = None,
1143
1168
  debug_info: dict = {},
1144
1169
  temperature: float = DEFAULT_COMPLETE_TEMPERATURE,
1145
1170
  timeout: int = DEFAULT_COMPLETE_TIMEOUT,
@@ -1147,7 +1172,7 @@ class ChatService:
1147
1172
  start_text: Optional[str] = None,
1148
1173
  other_options: Optional[dict] = None,
1149
1174
  ) -> LanguageModelResponse:
1150
- response = self.stream_complete(
1175
+ response = self.complete_with_references(
1151
1176
  messages=messages,
1152
1177
  model_name=model_name,
1153
1178
  content_chunks=content_chunks,
@@ -1161,11 +1186,12 @@ class ChatService:
1161
1186
 
1162
1187
  return LanguageModelResponse.from_stream_response(response)
1163
1188
 
1189
+ @deprecated("use complete_with_references_async instead.")
1164
1190
  async def stream_complete_async(
1165
1191
  self,
1166
1192
  messages: LanguageModelMessages,
1167
1193
  model_name: LanguageModelName | str,
1168
- content_chunks: list[ContentChunk] = [],
1194
+ content_chunks: list[ContentChunk] | None = None,
1169
1195
  debug_info: dict = {},
1170
1196
  temperature: float = DEFAULT_COMPLETE_TEMPERATURE,
1171
1197
  timeout: int = DEFAULT_COMPLETE_TIMEOUT,
@@ -1173,10 +1199,31 @@ class ChatService:
1173
1199
  start_text: Optional[str] = None,
1174
1200
  other_options: Optional[dict] = None,
1175
1201
  ) -> LanguageModelStreamResponse:
1176
- """
1177
- Streams a completion in the chat session asynchronously.
1178
- """
1202
+ """Stream a completion in the chat session asynchronously."""
1203
+ return await self.complete_with_references_async(
1204
+ messages=messages,
1205
+ model_name=model_name,
1206
+ content_chunks=content_chunks,
1207
+ debug_info=debug_info,
1208
+ temperature=temperature,
1209
+ timeout=timeout,
1210
+ tools=tools,
1211
+ start_text=start_text,
1212
+ other_options=other_options,
1213
+ )
1179
1214
 
1215
+ async def complete_with_references_async(
1216
+ self,
1217
+ messages: LanguageModelMessages,
1218
+ model_name: LanguageModelName | str,
1219
+ content_chunks: list[ContentChunk] | None = None,
1220
+ debug_info: dict = {},
1221
+ temperature: float = DEFAULT_COMPLETE_TEMPERATURE,
1222
+ timeout: int = DEFAULT_COMPLETE_TIMEOUT,
1223
+ tools: Optional[list[LanguageModelTool | LanguageModelToolDescription]] = None,
1224
+ start_text: Optional[str] = None,
1225
+ other_options: Optional[dict] = None,
1226
+ ) -> LanguageModelStreamResponse:
1180
1227
  [
1181
1228
  company_id,
1182
1229
  user_id,
@@ -1195,7 +1242,7 @@ class ChatService:
1195
1242
  ]
1196
1243
  )
1197
1244
 
1198
- return await stream_complete_to_chat_async(
1245
+ return await stream_complete_with_references_async(
1199
1246
  company_id=company_id,
1200
1247
  user_id=user_id,
1201
1248
  assistant_message_id=assistant_message_id,
@@ -1217,7 +1264,7 @@ class ChatService:
1217
1264
  self,
1218
1265
  messages: LanguageModelMessages,
1219
1266
  model_name: LanguageModelName | str,
1220
- content_chunks: list[ContentChunk] = [],
1267
+ content_chunks: list[ContentChunk] | None,
1221
1268
  debug_info: dict = {},
1222
1269
  temperature: float = DEFAULT_COMPLETE_TEMPERATURE,
1223
1270
  timeout: int = DEFAULT_COMPLETE_TIMEOUT,
@@ -1225,7 +1272,7 @@ class ChatService:
1225
1272
  start_text: Optional[str] = None,
1226
1273
  other_options: Optional[dict] = None,
1227
1274
  ) -> LanguageModelResponse:
1228
- response = self.stream_complete_async(
1275
+ response = self.complete_with_references_async(
1229
1276
  messages=messages,
1230
1277
  model_name=model_name,
1231
1278
  content_chunks=content_chunks,
@@ -1,23 +1,33 @@
1
+ import copy
1
2
  import logging
2
- from typing import cast
3
+ from datetime import datetime, timezone
4
+ from typing import Any, cast
3
5
 
4
6
  import unique_sdk
5
7
  from pydantic import BaseModel
6
8
 
9
+ from unique_toolkit.chat.schemas import ChatMessage, ChatMessageRole
7
10
  from unique_toolkit.content.schemas import ContentChunk
8
11
  from unique_toolkit.evaluators import DOMAIN_NAME
9
-
10
- from .constants import (
11
- DEFAULT_COMPLETE_TEMPERATURE,
12
- DEFAULT_COMPLETE_TIMEOUT,
13
- )
14
- from .infos import LanguageModelName
15
- from .schemas import (
12
+ from unique_toolkit.language_model import (
13
+ LanguageModelMessageRole,
16
14
  LanguageModelMessages,
17
15
  LanguageModelResponse,
16
+ LanguageModelStreamResponse,
17
+ LanguageModelStreamResponseMessage,
18
18
  LanguageModelTool,
19
19
  LanguageModelToolDescription,
20
20
  )
21
+ from unique_toolkit.language_model.infos import LanguageModelName
22
+ from unique_toolkit.language_model.reference import (
23
+ PotentialReference,
24
+ add_references_to_message,
25
+ )
26
+
27
+ from .constants import (
28
+ DEFAULT_COMPLETE_TEMPERATURE,
29
+ DEFAULT_COMPLETE_TIMEOUT,
30
+ )
21
31
 
22
32
  logger = logging.getLogger(f"toolkit.{DOMAIN_NAME}.{__name__}")
23
33
 
@@ -36,6 +46,7 @@ def complete(
36
46
  """Call the completion endpoint synchronously without streaming the response.
37
47
 
38
48
  Args:
49
+ ----
39
50
  company_id (str): The company ID associated with the request.
40
51
  messages (LanguageModelMessages): The messages to complete.
41
52
  model_name (LanguageModelName | str): The model name to use for the completion.
@@ -45,6 +56,7 @@ def complete(
45
56
  other_options (Optional[dict]): Additional options to use. Defaults to None.
46
57
 
47
58
  Returns:
59
+ -------
48
60
  LanguageModelResponse: The response object containing the completed result.
49
61
 
50
62
  """
@@ -93,6 +105,7 @@ async def complete_async(
93
105
  the completed result.
94
106
 
95
107
  Args:
108
+ ----
96
109
  company_id (str): The company ID associated with the request.
97
110
  messages (LanguageModelMessages): The messages to complete.
98
111
  model_name (LanguageModelName | str): The model name to use for the completion.
@@ -102,9 +115,11 @@ async def complete_async(
102
115
  other_options (Optional[dict]): The other options to use. Defaults to None.
103
116
 
104
117
  Returns:
118
+ -------
105
119
  LanguageModelResponse: The response object containing the completed result.
106
120
 
107
121
  Raises:
122
+ ------
108
123
  Exception: If an error occurs during the request, an exception is raised
109
124
  and logged.
110
125
 
@@ -198,7 +213,8 @@ def _prepare_completion_params_util(
198
213
  ) -> tuple[dict, str, dict, dict | None]:
199
214
  """Prepare common parameters for completion requests.
200
215
 
201
- Returns:
216
+ Returns
217
+ -------
202
218
  tuple containing:
203
219
  - options (dict): Combined options including tools and temperature
204
220
  - model (str): Resolved model name
@@ -232,3 +248,120 @@ def _prepare_completion_params_util(
232
248
  )
233
249
 
234
250
  return options, model, messages_dict, search_context
251
+
252
+
253
+ def complete_with_references(
254
+ company_id: str,
255
+ messages: LanguageModelMessages,
256
+ model_name: LanguageModelName | str,
257
+ content_chunks: list[ContentChunk] | None = None,
258
+ debug_dict: dict = {},
259
+ temperature: float = DEFAULT_COMPLETE_TEMPERATURE,
260
+ timeout: int = DEFAULT_COMPLETE_TIMEOUT,
261
+ tools: list[LanguageModelTool | LanguageModelToolDescription] | None = None,
262
+ start_text: str | None = None,
263
+ other_options: dict[str, Any] | None = None,
264
+ ) -> LanguageModelStreamResponse:
265
+ # Use toolkit language model functions for chat completion
266
+ response = complete(
267
+ company_id=company_id,
268
+ model_name=model_name,
269
+ messages=messages,
270
+ temperature=temperature,
271
+ timeout=timeout,
272
+ tools=tools,
273
+ other_options=other_options,
274
+ )
275
+
276
+ return _create_language_model_stream_response_with_references(
277
+ response=response,
278
+ content_chunks=content_chunks,
279
+ start_text=start_text,
280
+ )
281
+
282
+
283
+ async def complete_with_references_async(
284
+ company_id: str,
285
+ messages: LanguageModelMessages,
286
+ model_name: LanguageModelName | str,
287
+ content_chunks: list[ContentChunk] | None = None,
288
+ debug_dict: dict = {},
289
+ temperature: float = DEFAULT_COMPLETE_TEMPERATURE,
290
+ timeout: int = DEFAULT_COMPLETE_TIMEOUT,
291
+ tools: list[LanguageModelTool | LanguageModelToolDescription] | None = None,
292
+ start_text: str | None = None,
293
+ other_options: dict[str, Any] | None = None,
294
+ ) -> LanguageModelStreamResponse:
295
+ # Use toolkit language model functions for chat completion
296
+ response = await complete_async(
297
+ company_id=company_id,
298
+ model_name=model_name,
299
+ messages=messages,
300
+ temperature=temperature,
301
+ timeout=timeout,
302
+ tools=tools,
303
+ other_options=other_options,
304
+ )
305
+
306
+ return _create_language_model_stream_response_with_references(
307
+ response=response,
308
+ content_chunks=content_chunks,
309
+ start_text=start_text,
310
+ )
311
+
312
+
313
+ def _create_language_model_stream_response_with_references(
314
+ response: LanguageModelResponse,
315
+ content_chunks: list[ContentChunk] | None = None,
316
+ start_text: str | None = None,
317
+ ):
318
+ content = response.choices[0].message.content
319
+ content_chunks = content_chunks or []
320
+
321
+ if content is None:
322
+ raise ValueError("Content is None, which is not supported")
323
+ elif isinstance(content, list):
324
+ raise ValueError("Content is a list, which is not supported")
325
+ else:
326
+ content = start_text or "" + str(content)
327
+
328
+ message = ChatMessage(
329
+ id="msg_unknown",
330
+ text=copy.deepcopy(content),
331
+ role=ChatMessageRole.ASSISTANT,
332
+ created_at=datetime.now(timezone.utc),
333
+ chat_id="chat_unknown",
334
+ )
335
+
336
+ search_context = [
337
+ PotentialReference(
338
+ id=source.id,
339
+ chunk_id=source.id,
340
+ title=source.title,
341
+ key=source.key or "",
342
+ url=source.url,
343
+ )
344
+ for source in content_chunks
345
+ ]
346
+
347
+ message, __ = add_references_to_message(
348
+ message=message,
349
+ search_context=search_context,
350
+ )
351
+
352
+ stream_response_message = LanguageModelStreamResponseMessage(
353
+ id="stream_unknown",
354
+ previous_message_id=None,
355
+ role=LanguageModelMessageRole.ASSISTANT,
356
+ text=message.content or "",
357
+ original_text=content,
358
+ references=[u.model_dump() for u in message.references or []],
359
+ )
360
+
361
+ tool_calls = [r.function for r in response.choices[0].message.tool_calls or []]
362
+ tool_calls = tool_calls if len(tool_calls) > 0 else None
363
+
364
+ return LanguageModelStreamResponse(
365
+ message=stream_response_message,
366
+ tool_calls=tool_calls,
367
+ )
@@ -0,0 +1,244 @@
1
+ import re
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from unique_toolkit.chat.schemas import ChatMessage, Reference
6
+
7
+
8
+ class NodeReference(Reference):
9
+ original_index: list[int] = []
10
+ message_id: str | None = None
11
+
12
+
13
+ class PotentialReference(BaseModel):
14
+ id: str
15
+ chunk_id: str | None = None
16
+ title: str | None = None
17
+ key: str
18
+ url: str | None = None
19
+ internally_stored_at: str | None = None
20
+
21
+
22
+ def add_references_to_message(
23
+ message: ChatMessage,
24
+ search_context: list[PotentialReference],
25
+ model: str | None = None,
26
+ ) -> tuple[ChatMessage, bool]:
27
+ """Add references to a message and return the updated message with change status.
28
+
29
+ Returns:
30
+ Tuple[ChatMessage, bool]: (updated_message, references_changed)
31
+ """
32
+ if not message.content:
33
+ return message, False
34
+
35
+ if message.id is None:
36
+ raise ValueError("Message ID is required")
37
+
38
+ message.content = _preprocess_message(message.content)
39
+ text, ref_found = _add_references(
40
+ message.content, search_context, message.id, model
41
+ )
42
+ message.content = _postprocess_message(text)
43
+
44
+ message.references = [Reference(**ref.model_dump()) for ref in ref_found]
45
+ references_changed = len(ref_found) > 0
46
+ return message, references_changed
47
+
48
+
49
+ def _add_references(
50
+ text: str,
51
+ search_context: list[PotentialReference],
52
+ message_id: str,
53
+ model: str | None = None,
54
+ ) -> tuple[str, list[NodeReference]]:
55
+ """Add references to text and return the processed text with reference status.
56
+
57
+ Returns:
58
+ Tuple[str, bool]: (processed_text, ref_found)
59
+ """
60
+ references = _find_references(
61
+ text=text,
62
+ search_context=search_context,
63
+ message_id=message_id,
64
+ )
65
+
66
+ # Only reference a source once, even if it is mentioned multiple times in the text.
67
+ with_footnotes = _add_footnotes_to_text(text=text, references=references)
68
+
69
+ # Gemini 2.5 flash model has tendency to add multiple references for the same fact
70
+ # This is a workaround to limit the number of references to 5
71
+ if model and model.startswith("litellm:gemini-2-5-flash"):
72
+ reduced_text = _limit_consecutive_source_references(with_footnotes)
73
+
74
+ # Get the references that remain after reduction
75
+ remaining_numbers = set()
76
+ sup_matches = re.findall(r"<sup>(\d+)</sup>", reduced_text)
77
+ remaining_numbers = {int(match) for match in sup_matches}
78
+
79
+ references = [
80
+ ref for ref in references if ref.sequence_number in remaining_numbers
81
+ ]
82
+ text = _remove_hallucinated_references(reduced_text)
83
+ else:
84
+ text = _remove_hallucinated_references(with_footnotes)
85
+
86
+ return text, references
87
+
88
+
89
+ def _preprocess_message(text: str) -> str:
90
+ """Preprocess message text to normalize reference formats."""
91
+ # Remove user & assistant references: XML format '[<user>]', '[\<user>]', etc.
92
+ patterns = [
93
+ (r"\[(\\)?(<)?user(>)?\]", ""),
94
+ (r"\[(\\)?(<)?assistant(>)?\]", ""),
95
+ (r"source[\s]?\[(\\)?(<)?conversation(>)?\]", "the previous conversation"),
96
+ (r"\[(\\)?(<)?previous[_,\s]conversation(>)?\]", ""),
97
+ (r"\[(\\)?(<)?past[_,\s]conversation(>)?\]", ""),
98
+ (r"\[(\\)?(<)?previous[_,\s]?answer(>)?\]", ""),
99
+ (r"\[(\\)?(<)?previous[_,\s]question(>)?\]", ""),
100
+ (r"\[(\\)?(<)?conversation(>)?\]", ""),
101
+ (r"\[(\\)?(<)?none(>)?\]", ""),
102
+ ]
103
+
104
+ for pattern, replacement in patterns:
105
+ text = re.sub(pattern, replacement, text, flags=re.IGNORECASE)
106
+
107
+ # Replace XML format '[<source XX>]', '[<sourceXX>]' and '[\<sourceXX>]' with [XX]
108
+ text = re.sub(r"\[(\\)?<source[\s]?(\d+)>\]", r"[\2]", text)
109
+
110
+ # Replace format '[source XX]' and '[sourceXX]' with [XX]
111
+ text = re.sub(r"\[source[\s]?(\d+)\]", r"[\1]", text)
112
+
113
+ # Make all references non-bold
114
+ text = re.sub(r"\[\*\*(\d+)\*\*\]", r"[\1]", text)
115
+
116
+ # Replace 'SOURCEXX' and 'SOURCE XX' with [XX]
117
+ text = re.sub(r"source[\s]?(\d+)", r"[\1]", text, flags=re.IGNORECASE)
118
+
119
+ # Replace 'SOURCE n°X' with [XX]
120
+ text = re.sub(r"source[\s]?n°(\d+)", r"[\1]", text, flags=re.IGNORECASE)
121
+
122
+ # Replace '[<[XX]>]' and '[\<[XX]>]' with [XX]
123
+ text = re.sub(r"\[(\\)?\[?<\[(\d+)\]?\]>\]", r"[\2]", text)
124
+
125
+ # Replace '[[A], [B], ...]' or '[[A], B, C, ...]' with [A][B][C]...
126
+ def replace_combined_brackets(match):
127
+ numbers = re.findall(r"\d+", match.group(0))
128
+ return "".join(f"[{n}]" for n in numbers)
129
+
130
+ text = re.sub(
131
+ r"\[\[(\d+)\](?:,\s*(?:\[)?\d+(?:\])?)*\]", replace_combined_brackets, text
132
+ )
133
+
134
+ return text
135
+
136
+
137
+ def _limit_consecutive_source_references(text: str) -> str:
138
+ """Limit consecutive source references to maximum 5 unique sources."""
139
+
140
+ def replace_consecutive(match):
141
+ # Extract all numbers from the match and get unique values
142
+ numbers = list(set(re.findall(r"\d+", match.group(0))))
143
+ # Take only the first five unique numbers
144
+ return "".join(f"<sup>{n}</sup>" for n in numbers[:5])
145
+
146
+ # Find sequences of 5+ consecutive sources
147
+ pattern = r"(?:<sup>\d+</sup>){5,}"
148
+ return re.sub(pattern, replace_consecutive, text)
149
+
150
+
151
+ def _postprocess_message(text: str) -> str:
152
+ """Format superscript references to remove duplicates."""
153
+
154
+ def replace_sup_sequence(match):
155
+ # Extract unique numbers from the entire match
156
+ sup_numbers = set(re.findall(r"\d+", match.group(0)))
157
+ return "".join(f"<sup>{n}</sup>" for n in sup_numbers)
158
+
159
+ # Find sequences of 2+ superscripts including internal spaces
160
+ pattern = r"(<sup>\d+</sup>[ ]*)+<sup>\d+</sup>"
161
+ return re.sub(pattern, replace_sup_sequence, text)
162
+
163
+
164
+ def _get_max_sub_count_in_text(text: str) -> int:
165
+ """Get the maximum superscript number in the text."""
166
+ matches = re.findall(r"<sup>(\d+)</sup>", text)
167
+ return max((int(match) for match in matches), default=0)
168
+
169
+
170
+ def _find_references(
171
+ text: str,
172
+ search_context: list[PotentialReference],
173
+ message_id: str,
174
+ ) -> list[NodeReference]:
175
+ """Find references in text based on search context."""
176
+ references: list[NodeReference] = []
177
+ sequence_number = 1 + _get_max_sub_count_in_text(text)
178
+
179
+ # Find all numbers in brackets to ensure we get references in order of occurrence
180
+ numbers_in_brackets = _extract_numbers_in_brackets(text)
181
+
182
+ for number in numbers_in_brackets:
183
+ # Convert 1-based reference to 0-based index
184
+ index = number - 1
185
+ if index < 0 or index >= len(search_context):
186
+ continue
187
+
188
+ search = search_context[index]
189
+ if not search:
190
+ continue
191
+
192
+ # Don't put the reference twice
193
+ reference_name = search.title or search.key
194
+ found_reference = next(
195
+ (r for r in references if r.name == reference_name), None
196
+ )
197
+
198
+ if found_reference:
199
+ found_reference.original_index.append(number)
200
+ continue
201
+
202
+ url = (
203
+ search.url
204
+ if search.url and not search.internally_stored_at
205
+ else f"unique://content/{search.id}"
206
+ )
207
+
208
+ references.append(
209
+ NodeReference(
210
+ name=reference_name,
211
+ url=url,
212
+ sequence_number=sequence_number,
213
+ original_index=[number],
214
+ source_id=f"{search.id}_{search.chunk_id}"
215
+ if search.chunk_id
216
+ else search.id,
217
+ source="node-ingestion-chunks",
218
+ message_id=message_id,
219
+ )
220
+ )
221
+ sequence_number += 1
222
+
223
+ return references
224
+
225
+
226
+ def _extract_numbers_in_brackets(text: str) -> list[int]:
227
+ """Extract numbers from [X] format in text."""
228
+ matches = re.findall(r"\[(\d+)\]", text)
229
+ return [int(match) for match in matches]
230
+
231
+
232
+ def _add_footnotes_to_text(text: str, references: list[NodeReference]) -> str:
233
+ """Replace bracket references with superscript footnotes."""
234
+ for reference in references:
235
+ for original_index in reference.original_index:
236
+ text = text.replace(
237
+ f"[{original_index}]", f"<sup>{reference.sequence_number}</sup>"
238
+ )
239
+ return text
240
+
241
+
242
+ def _remove_hallucinated_references(text: str) -> str:
243
+ """Remove any remaining bracket references that weren't converted."""
244
+ return re.sub(r"\[\d+\]", "", text).strip()
@@ -1,11 +1,12 @@
1
1
  import logging
2
- from typing import Optional, Type
2
+ from typing import Any, Optional, Type
3
3
 
4
4
  from pydantic import BaseModel
5
5
  from typing_extensions import deprecated
6
6
 
7
7
  from unique_toolkit._common.validate_required_values import validate_required_values
8
8
  from unique_toolkit.app.schemas import BaseEvent, ChatEvent, Event
9
+ from unique_toolkit.content.schemas import ContentChunk
9
10
  from unique_toolkit.language_model.constants import (
10
11
  DEFAULT_COMPLETE_TEMPERATURE,
11
12
  DEFAULT_COMPLETE_TIMEOUT,
@@ -14,11 +15,14 @@ from unique_toolkit.language_model.constants import (
14
15
  from unique_toolkit.language_model.functions import (
15
16
  complete,
16
17
  complete_async,
18
+ complete_with_references,
19
+ complete_with_references_async,
17
20
  )
18
21
  from unique_toolkit.language_model.infos import LanguageModelName
19
22
  from unique_toolkit.language_model.schemas import (
20
23
  LanguageModelMessages,
21
24
  LanguageModelResponse,
25
+ LanguageModelStreamResponse,
22
26
  LanguageModelTool,
23
27
  LanguageModelToolDescription,
24
28
  )
@@ -260,3 +264,55 @@ class LanguageModelService:
260
264
  structured_output_model=structured_output_model,
261
265
  structured_output_enforce_schema=structured_output_enforce_schema,
262
266
  )
267
+
268
+ def complete_with_references(
269
+ self,
270
+ messages: LanguageModelMessages,
271
+ model_name: LanguageModelName | str,
272
+ content_chunks: list[ContentChunk] | None = None,
273
+ debug_info: dict = {},
274
+ temperature: float = DEFAULT_COMPLETE_TEMPERATURE,
275
+ timeout: int = DEFAULT_COMPLETE_TIMEOUT,
276
+ tools: list[LanguageModelTool | LanguageModelToolDescription] | None = None,
277
+ start_text: str | None = None,
278
+ other_options: dict[str, Any] | None = None,
279
+ ) -> LanguageModelStreamResponse:
280
+ [company_id] = validate_required_values([self._company_id])
281
+
282
+ return complete_with_references(
283
+ company_id=company_id,
284
+ messages=messages,
285
+ model_name=model_name,
286
+ content_chunks=content_chunks,
287
+ temperature=temperature,
288
+ timeout=timeout,
289
+ other_options=other_options,
290
+ tools=tools,
291
+ start_text=start_text,
292
+ )
293
+
294
+ async def complete_with_references_async(
295
+ self,
296
+ messages: LanguageModelMessages,
297
+ model_name: LanguageModelName | str,
298
+ content_chunks: list[ContentChunk] | None = None,
299
+ debug_info: dict = {},
300
+ temperature: float = DEFAULT_COMPLETE_TEMPERATURE,
301
+ timeout: int = DEFAULT_COMPLETE_TIMEOUT,
302
+ tools: list[LanguageModelTool | LanguageModelToolDescription] | None = None,
303
+ start_text: str | None = None,
304
+ other_options: dict[str, Any] | None = None,
305
+ ) -> LanguageModelStreamResponse:
306
+ [company_id] = validate_required_values([self._company_id])
307
+
308
+ return await complete_with_references_async(
309
+ company_id=company_id,
310
+ messages=messages,
311
+ model_name=model_name,
312
+ content_chunks=content_chunks,
313
+ temperature=temperature,
314
+ timeout=timeout,
315
+ other_options=other_options,
316
+ tools=tools,
317
+ start_text=start_text,
318
+ )
@@ -1,9 +1,11 @@
1
- from typing import Protocol
1
+ from typing import Any, Awaitable, Protocol
2
2
 
3
+ from unique_toolkit.content import ContentChunk
3
4
  from unique_toolkit.language_model import (
4
5
  LanguageModelMessages,
5
6
  LanguageModelName,
6
7
  LanguageModelResponse,
8
+ LanguageModelStreamResponse,
7
9
  LanguageModelTool,
8
10
  LanguageModelToolDescription,
9
11
  )
@@ -25,5 +27,37 @@ class SupportsComplete(Protocol):
25
27
  temperature: float = DEFAULT_COMPLETE_TEMPERATURE,
26
28
  timeout: int = DEFAULT_COMPLETE_TIMEOUT,
27
29
  tools: list[LanguageModelTool | LanguageModelToolDescription] | None = None,
28
- **kwargs,
29
30
  ) -> LanguageModelResponse: ...
31
+
32
+ async def complete_async(
33
+ self,
34
+ messages: LanguageModelMessages,
35
+ model_name: LanguageModelName | str,
36
+ temperature: float = DEFAULT_COMPLETE_TEMPERATURE,
37
+ timeout: int = DEFAULT_COMPLETE_TIMEOUT,
38
+ tools: list[LanguageModelTool | LanguageModelToolDescription] | None = None,
39
+ ) -> Awaitable[LanguageModelResponse]: ...
40
+
41
+
42
+ class SupportCompleteWithReferences(Protocol):
43
+ def complete_with_references(
44
+ self,
45
+ messages: LanguageModelMessages,
46
+ model_name: LanguageModelName | str,
47
+ content_chunks: list[ContentChunk] | None = None,
48
+ debug_info: dict[str, Any] = {},
49
+ temperature: float = DEFAULT_COMPLETE_TEMPERATURE,
50
+ timeout: int = DEFAULT_COMPLETE_TIMEOUT,
51
+ tools: list[LanguageModelTool | LanguageModelToolDescription] | None = None,
52
+ ) -> LanguageModelStreamResponse: ...
53
+
54
+ def complete_with_references_async(
55
+ self,
56
+ messages: LanguageModelMessages,
57
+ model_name: LanguageModelName | str,
58
+ content_chunks: list[ContentChunk] | None = None,
59
+ debug_info: dict[str, Any] = {},
60
+ temperature: float = DEFAULT_COMPLETE_TEMPERATURE,
61
+ timeout: int = DEFAULT_COMPLETE_TIMEOUT,
62
+ tools: list[LanguageModelTool | LanguageModelToolDescription] | None = None,
63
+ ) -> Awaitable[LanguageModelStreamResponse]: ...
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: unique_toolkit
3
- Version: 0.7.26
3
+ Version: 0.7.28
4
4
  Summary:
5
5
  License: Proprietary
6
6
  Author: Martin Fadler
@@ -111,6 +111,13 @@ All notable changes to this project will be documented in this file.
111
111
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
112
112
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
113
113
 
114
+ ## [0.7.28] - 2025-06-17
115
+ - Revert default factory change on `ChatEventPayload` for attribute `metadata_filter` due to error in `backend-ingestion` on empty dict
116
+
117
+ ## [0.7.27] - 2025-06-16
118
+ - Introduce a protocol for `complete_with_references` to enable testable services
119
+ - Rename/Create functions `stream_complete` in chat service and llm service accordingly
120
+
114
121
 
115
122
  ## [0.7.26] - 2025-06-05
116
123
  - Add `scope_rules` to `ChatEventPayload`
@@ -9,13 +9,13 @@ unique_toolkit/app/init_logging.py,sha256=Sh26SRxOj8i8dzobKhYha2lLrkrMTHfB1V4jR3
9
9
  unique_toolkit/app/init_sdk.py,sha256=Nv4Now4pMfM0AgRhbtatLpm_39rKxn0WmRLwmPhRl-8,1285
10
10
  unique_toolkit/app/performance/async_tasks.py,sha256=H0l3OAcosLwNHZ8d2pd-Di4wHIXfclEvagi5kfqLFPA,1941
11
11
  unique_toolkit/app/performance/async_wrapper.py,sha256=yVVcRDkcdyfjsxro-N29SBvi-7773wnfDplef6-y8xw,1077
12
- unique_toolkit/app/schemas.py,sha256=c2Tu3woqc7gGuHYEspL0mOEyvmqOxTcavbnzZJhSYAs,3969
12
+ unique_toolkit/app/schemas.py,sha256=2IeOGnjMc9M_3u4ZvPWegMmQVWlW_teTbNFxjjJA_E4,5008
13
13
  unique_toolkit/app/verification.py,sha256=GxFFwcJMy25fCA_Xe89wKW7bgqOu8PAs5y8QpHF0GSc,3861
14
14
  unique_toolkit/chat/__init__.py,sha256=LRs2G-JTVuci4lbtHTkVUiNcZcSR6uqqfnAyo7af6nY,619
15
15
  unique_toolkit/chat/constants.py,sha256=05kq6zjqUVB2d6_P7s-90nbljpB3ryxwCI-CAz0r2O4,83
16
- unique_toolkit/chat/functions.py,sha256=TP55fSVXWTO3OoGUuYBuK9cBHUw96wlQGbVfhhMalCI,27332
16
+ unique_toolkit/chat/functions.py,sha256=QsJVhBXgK6jDWRYpEAt-22jy5NKGsYs4fmHrOEdHAyc,29865
17
17
  unique_toolkit/chat/schemas.py,sha256=ct3BbvdQmpcNeDmJdc-Iz33LX1_3O-SGVgrsuT0B99k,2881
18
- unique_toolkit/chat/service.py,sha256=K7XtB3IdKznNu1r4dy2dXiwZYQg_vKgRUD52RoKewQU,40799
18
+ unique_toolkit/chat/service.py,sha256=jAgInqq7wU-erezl-BOS9BQ3DOR1nrbVFlMfACn26XI,42827
19
19
  unique_toolkit/chat/state.py,sha256=Cjgwv_2vhDFbV69xxsn7SefhaoIAEqLx3ferdVFCnOg,1445
20
20
  unique_toolkit/chat/utils.py,sha256=ihm-wQykBWhB4liR3LnwPVPt_qGW6ETq21Mw4HY0THE,854
21
21
  unique_toolkit/content/__init__.py,sha256=EdJg_A_7loEtCQf4cah3QARQreJx6pdz89Rm96YbMVg,940
@@ -47,13 +47,14 @@ unique_toolkit/evaluators/schemas.py,sha256=Jaue6Uhx75X1CyHKWj8sT3RE1JZXTqoLtfLt
47
47
  unique_toolkit/language_model/__init__.py,sha256=lRQyLlbwHbNFf4-0foBU13UGb09lwEeodbVsfsSgaCk,1971
48
48
  unique_toolkit/language_model/builder.py,sha256=69WCcmkm2rMP2-YEH_EjHiEp6OzwjwCs8VbhjVJaCe0,3168
49
49
  unique_toolkit/language_model/constants.py,sha256=B-topqW0r83dkC_25DeQfnPk3n53qzIHUCBS7YJ0-1U,119
50
- unique_toolkit/language_model/functions.py,sha256=koCAfhtkIGSiy8pSdDpIw9xRbwJ20EeLhDQMUXc8KZk,8049
50
+ unique_toolkit/language_model/functions.py,sha256=J54mzKs-uOBejpiMDa1YviKvJDcYl0gvEF1CfLb7_S4,12208
51
51
  unique_toolkit/language_model/infos.py,sha256=peJ4cSJC__jGLWZoOZGRhoersmkwFmclsXTZi-KqYXc,30723
52
52
  unique_toolkit/language_model/prompt.py,sha256=JSawaLjQg3VR-E2fK8engFyJnNdk21zaO8pPIodzN4Q,3991
53
+ unique_toolkit/language_model/reference.py,sha256=TrRfnxd-cjpdijmZ1dKII87aEACIbW7iU_cE9gtGuH4,8314
53
54
  unique_toolkit/language_model/schemas.py,sha256=DJD2aoMfs2Irnc4rzOrVuV4Fbt84LQAiDGG5rse1dgk,12770
54
- unique_toolkit/language_model/service.py,sha256=9LS3ouRNtzqZaKrMFagLZS9gBvNC5e46Ut86YWHBBHY,8470
55
+ unique_toolkit/language_model/service.py,sha256=PJ0vOGPxzqsQXTQma8blLT4DfGP4gVZVcX2v1GVYozI,10610
55
56
  unique_toolkit/language_model/utils.py,sha256=bPQ4l6_YO71w-zaIPanUUmtbXC1_hCvLK0tAFc3VCRc,1902
56
- unique_toolkit/protocols/support.py,sha256=SD17M8jgjtzCh0bgDXgKrX96n6DizF1PT2SZIhyt4n8,888
57
+ unique_toolkit/protocols/support.py,sha256=V15WEIFKVMyF1QCnR8vIi4GrJy4dfTCB6d6JlqPZ58o,2341
57
58
  unique_toolkit/short_term_memory/__init__.py,sha256=2mI3AUrffgH7Yt-xS57EGqnHf7jnn6xquoKEhJqk3Wg,185
58
59
  unique_toolkit/short_term_memory/constants.py,sha256=698CL6-wjup2MvU19RxSmQk3gX7aqW_OOpZB7sbz_Xg,34
59
60
  unique_toolkit/short_term_memory/functions.py,sha256=3WiK-xatY5nh4Dr5zlDUye1k3E6kr41RiscwtTplw5k,4484
@@ -61,7 +62,7 @@ unique_toolkit/short_term_memory/schemas.py,sha256=OhfcXyF6ACdwIXW45sKzjtZX_gkcJ
61
62
  unique_toolkit/short_term_memory/service.py,sha256=vEKFxP1SScPrFniso492fVthWR1sosdFibhiNF3zRvI,8081
62
63
  unique_toolkit/smart_rules/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
63
64
  unique_toolkit/smart_rules/compile.py,sha256=44qDrrKD-bKCjjyUep9qa1IwNkneXoQezfFoVm1QToM,9558
64
- unique_toolkit-0.7.26.dist-info/LICENSE,sha256=GlN8wHNdh53xwOPg44URnwag6TEolCjoq3YD_KrWgss,193
65
- unique_toolkit-0.7.26.dist-info/METADATA,sha256=Au6JVHERLYsvDn8yH4tT_K_aB-fK3RhB83-GMIrIjCc,23803
66
- unique_toolkit-0.7.26.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
67
- unique_toolkit-0.7.26.dist-info/RECORD,,
65
+ unique_toolkit-0.7.28.dist-info/LICENSE,sha256=GlN8wHNdh53xwOPg44URnwag6TEolCjoq3YD_KrWgss,193
66
+ unique_toolkit-0.7.28.dist-info/METADATA,sha256=CR6v3EWcXcTcDcL05cVP7LjRk9wE1Bwe6qPii5sKFjc,24161
67
+ unique_toolkit-0.7.28.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
68
+ unique_toolkit-0.7.28.dist-info/RECORD,,