unique_toolkit 0.7.7__py3-none-any.whl → 1.23.0__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.

Potentially problematic release.


This version of unique_toolkit might be problematic. Click here for more details.

Files changed (166) hide show
  1. unique_toolkit/__init__.py +28 -1
  2. unique_toolkit/_common/api_calling/human_verification_manager.py +343 -0
  3. unique_toolkit/_common/base_model_type_attribute.py +303 -0
  4. unique_toolkit/_common/chunk_relevancy_sorter/config.py +49 -0
  5. unique_toolkit/_common/chunk_relevancy_sorter/exception.py +5 -0
  6. unique_toolkit/_common/chunk_relevancy_sorter/schemas.py +46 -0
  7. unique_toolkit/_common/chunk_relevancy_sorter/service.py +374 -0
  8. unique_toolkit/_common/chunk_relevancy_sorter/tests/test_service.py +275 -0
  9. unique_toolkit/_common/default_language_model.py +12 -0
  10. unique_toolkit/_common/docx_generator/__init__.py +7 -0
  11. unique_toolkit/_common/docx_generator/config.py +12 -0
  12. unique_toolkit/_common/docx_generator/schemas.py +80 -0
  13. unique_toolkit/_common/docx_generator/service.py +252 -0
  14. unique_toolkit/_common/docx_generator/template/Doc Template.docx +0 -0
  15. unique_toolkit/_common/endpoint_builder.py +305 -0
  16. unique_toolkit/_common/endpoint_requestor.py +430 -0
  17. unique_toolkit/_common/exception.py +24 -0
  18. unique_toolkit/_common/feature_flags/schema.py +9 -0
  19. unique_toolkit/_common/pydantic/rjsf_tags.py +936 -0
  20. unique_toolkit/_common/pydantic_helpers.py +154 -0
  21. unique_toolkit/_common/referencing.py +53 -0
  22. unique_toolkit/_common/string_utilities.py +140 -0
  23. unique_toolkit/_common/tests/test_referencing.py +521 -0
  24. unique_toolkit/_common/tests/test_string_utilities.py +506 -0
  25. unique_toolkit/_common/token/image_token_counting.py +67 -0
  26. unique_toolkit/_common/token/token_counting.py +204 -0
  27. unique_toolkit/_common/utils/__init__.py +1 -0
  28. unique_toolkit/_common/utils/files.py +43 -0
  29. unique_toolkit/_common/utils/structured_output/__init__.py +1 -0
  30. unique_toolkit/_common/utils/structured_output/schema.py +5 -0
  31. unique_toolkit/_common/utils/write_configuration.py +51 -0
  32. unique_toolkit/_common/validators.py +101 -4
  33. unique_toolkit/agentic/__init__.py +1 -0
  34. unique_toolkit/agentic/debug_info_manager/debug_info_manager.py +28 -0
  35. unique_toolkit/agentic/debug_info_manager/test/test_debug_info_manager.py +278 -0
  36. unique_toolkit/agentic/evaluation/config.py +36 -0
  37. unique_toolkit/{evaluators → agentic/evaluation}/context_relevancy/prompts.py +25 -0
  38. unique_toolkit/agentic/evaluation/context_relevancy/schema.py +80 -0
  39. unique_toolkit/agentic/evaluation/context_relevancy/service.py +273 -0
  40. unique_toolkit/agentic/evaluation/evaluation_manager.py +218 -0
  41. unique_toolkit/agentic/evaluation/hallucination/constants.py +61 -0
  42. unique_toolkit/agentic/evaluation/hallucination/hallucination_evaluation.py +111 -0
  43. unique_toolkit/{evaluators → agentic/evaluation}/hallucination/prompts.py +1 -1
  44. unique_toolkit/{evaluators → agentic/evaluation}/hallucination/service.py +16 -15
  45. unique_toolkit/{evaluators → agentic/evaluation}/hallucination/utils.py +30 -20
  46. unique_toolkit/{evaluators → agentic/evaluation}/output_parser.py +20 -2
  47. unique_toolkit/{evaluators → agentic/evaluation}/schemas.py +27 -7
  48. unique_toolkit/agentic/evaluation/tests/test_context_relevancy_service.py +253 -0
  49. unique_toolkit/agentic/evaluation/tests/test_output_parser.py +87 -0
  50. unique_toolkit/agentic/history_manager/history_construction_with_contents.py +297 -0
  51. unique_toolkit/agentic/history_manager/history_manager.py +242 -0
  52. unique_toolkit/agentic/history_manager/loop_token_reducer.py +484 -0
  53. unique_toolkit/agentic/history_manager/utils.py +96 -0
  54. unique_toolkit/agentic/postprocessor/postprocessor_manager.py +212 -0
  55. unique_toolkit/agentic/reference_manager/reference_manager.py +103 -0
  56. unique_toolkit/agentic/responses_api/__init__.py +19 -0
  57. unique_toolkit/agentic/responses_api/postprocessors/code_display.py +63 -0
  58. unique_toolkit/agentic/responses_api/postprocessors/generated_files.py +145 -0
  59. unique_toolkit/agentic/responses_api/stream_handler.py +15 -0
  60. unique_toolkit/agentic/short_term_memory_manager/persistent_short_term_memory_manager.py +141 -0
  61. unique_toolkit/agentic/thinking_manager/thinking_manager.py +103 -0
  62. unique_toolkit/agentic/tools/__init__.py +1 -0
  63. unique_toolkit/agentic/tools/a2a/__init__.py +36 -0
  64. unique_toolkit/agentic/tools/a2a/config.py +17 -0
  65. unique_toolkit/agentic/tools/a2a/evaluation/__init__.py +15 -0
  66. unique_toolkit/agentic/tools/a2a/evaluation/_utils.py +66 -0
  67. unique_toolkit/agentic/tools/a2a/evaluation/config.py +55 -0
  68. unique_toolkit/agentic/tools/a2a/evaluation/evaluator.py +260 -0
  69. unique_toolkit/agentic/tools/a2a/evaluation/summarization_user_message.j2 +9 -0
  70. unique_toolkit/agentic/tools/a2a/manager.py +55 -0
  71. unique_toolkit/agentic/tools/a2a/postprocessing/__init__.py +21 -0
  72. unique_toolkit/agentic/tools/a2a/postprocessing/_display_utils.py +185 -0
  73. unique_toolkit/agentic/tools/a2a/postprocessing/_ref_utils.py +73 -0
  74. unique_toolkit/agentic/tools/a2a/postprocessing/config.py +45 -0
  75. unique_toolkit/agentic/tools/a2a/postprocessing/display.py +180 -0
  76. unique_toolkit/agentic/tools/a2a/postprocessing/references.py +101 -0
  77. unique_toolkit/agentic/tools/a2a/postprocessing/test/test_display_utils.py +1335 -0
  78. unique_toolkit/agentic/tools/a2a/postprocessing/test/test_ref_utils.py +603 -0
  79. unique_toolkit/agentic/tools/a2a/prompts.py +46 -0
  80. unique_toolkit/agentic/tools/a2a/response_watcher/__init__.py +6 -0
  81. unique_toolkit/agentic/tools/a2a/response_watcher/service.py +91 -0
  82. unique_toolkit/agentic/tools/a2a/tool/__init__.py +4 -0
  83. unique_toolkit/agentic/tools/a2a/tool/_memory.py +26 -0
  84. unique_toolkit/agentic/tools/a2a/tool/_schema.py +9 -0
  85. unique_toolkit/agentic/tools/a2a/tool/config.py +73 -0
  86. unique_toolkit/agentic/tools/a2a/tool/service.py +306 -0
  87. unique_toolkit/agentic/tools/agent_chunks_hanlder.py +65 -0
  88. unique_toolkit/agentic/tools/config.py +167 -0
  89. unique_toolkit/agentic/tools/factory.py +44 -0
  90. unique_toolkit/agentic/tools/mcp/__init__.py +4 -0
  91. unique_toolkit/agentic/tools/mcp/manager.py +71 -0
  92. unique_toolkit/agentic/tools/mcp/models.py +28 -0
  93. unique_toolkit/agentic/tools/mcp/tool_wrapper.py +234 -0
  94. unique_toolkit/agentic/tools/openai_builtin/__init__.py +11 -0
  95. unique_toolkit/agentic/tools/openai_builtin/base.py +30 -0
  96. unique_toolkit/agentic/tools/openai_builtin/code_interpreter/__init__.py +8 -0
  97. unique_toolkit/agentic/tools/openai_builtin/code_interpreter/config.py +57 -0
  98. unique_toolkit/agentic/tools/openai_builtin/code_interpreter/service.py +230 -0
  99. unique_toolkit/agentic/tools/openai_builtin/manager.py +62 -0
  100. unique_toolkit/agentic/tools/schemas.py +141 -0
  101. unique_toolkit/agentic/tools/test/test_mcp_manager.py +536 -0
  102. unique_toolkit/agentic/tools/test/test_tool_progress_reporter.py +445 -0
  103. unique_toolkit/agentic/tools/tool.py +183 -0
  104. unique_toolkit/agentic/tools/tool_manager.py +523 -0
  105. unique_toolkit/agentic/tools/tool_progress_reporter.py +285 -0
  106. unique_toolkit/agentic/tools/utils/__init__.py +19 -0
  107. unique_toolkit/agentic/tools/utils/execution/__init__.py +1 -0
  108. unique_toolkit/agentic/tools/utils/execution/execution.py +286 -0
  109. unique_toolkit/agentic/tools/utils/source_handling/__init__.py +0 -0
  110. unique_toolkit/agentic/tools/utils/source_handling/schema.py +21 -0
  111. unique_toolkit/agentic/tools/utils/source_handling/source_formatting.py +207 -0
  112. unique_toolkit/agentic/tools/utils/source_handling/tests/test_source_formatting.py +216 -0
  113. unique_toolkit/app/__init__.py +6 -0
  114. unique_toolkit/app/dev_util.py +180 -0
  115. unique_toolkit/app/init_sdk.py +32 -1
  116. unique_toolkit/app/schemas.py +198 -31
  117. unique_toolkit/app/unique_settings.py +367 -0
  118. unique_toolkit/chat/__init__.py +8 -1
  119. unique_toolkit/chat/deprecated/service.py +232 -0
  120. unique_toolkit/chat/functions.py +642 -77
  121. unique_toolkit/chat/rendering.py +34 -0
  122. unique_toolkit/chat/responses_api.py +461 -0
  123. unique_toolkit/chat/schemas.py +133 -2
  124. unique_toolkit/chat/service.py +115 -767
  125. unique_toolkit/content/functions.py +153 -4
  126. unique_toolkit/content/schemas.py +122 -15
  127. unique_toolkit/content/service.py +278 -44
  128. unique_toolkit/content/smart_rules.py +301 -0
  129. unique_toolkit/content/utils.py +8 -3
  130. unique_toolkit/embedding/service.py +102 -11
  131. unique_toolkit/framework_utilities/__init__.py +1 -0
  132. unique_toolkit/framework_utilities/langchain/client.py +71 -0
  133. unique_toolkit/framework_utilities/langchain/history.py +19 -0
  134. unique_toolkit/framework_utilities/openai/__init__.py +6 -0
  135. unique_toolkit/framework_utilities/openai/client.py +83 -0
  136. unique_toolkit/framework_utilities/openai/message_builder.py +229 -0
  137. unique_toolkit/framework_utilities/utils.py +23 -0
  138. unique_toolkit/language_model/__init__.py +3 -0
  139. unique_toolkit/language_model/builder.py +27 -11
  140. unique_toolkit/language_model/default_language_model.py +3 -0
  141. unique_toolkit/language_model/functions.py +327 -43
  142. unique_toolkit/language_model/infos.py +992 -50
  143. unique_toolkit/language_model/reference.py +242 -0
  144. unique_toolkit/language_model/schemas.py +475 -48
  145. unique_toolkit/language_model/service.py +228 -27
  146. unique_toolkit/protocols/support.py +145 -0
  147. unique_toolkit/services/__init__.py +7 -0
  148. unique_toolkit/services/chat_service.py +1630 -0
  149. unique_toolkit/services/knowledge_base.py +861 -0
  150. unique_toolkit/short_term_memory/service.py +178 -41
  151. unique_toolkit/smart_rules/__init__.py +0 -0
  152. unique_toolkit/smart_rules/compile.py +56 -0
  153. unique_toolkit/test_utilities/events.py +197 -0
  154. {unique_toolkit-0.7.7.dist-info → unique_toolkit-1.23.0.dist-info}/METADATA +606 -7
  155. unique_toolkit-1.23.0.dist-info/RECORD +182 -0
  156. unique_toolkit/evaluators/__init__.py +0 -1
  157. unique_toolkit/evaluators/config.py +0 -35
  158. unique_toolkit/evaluators/constants.py +0 -1
  159. unique_toolkit/evaluators/context_relevancy/constants.py +0 -32
  160. unique_toolkit/evaluators/context_relevancy/service.py +0 -53
  161. unique_toolkit/evaluators/context_relevancy/utils.py +0 -142
  162. unique_toolkit/evaluators/hallucination/constants.py +0 -41
  163. unique_toolkit-0.7.7.dist-info/RECORD +0 -64
  164. /unique_toolkit/{evaluators → agentic/evaluation}/exception.py +0 -0
  165. {unique_toolkit-0.7.7.dist-info → unique_toolkit-1.23.0.dist-info}/LICENSE +0 -0
  166. {unique_toolkit-0.7.7.dist-info → unique_toolkit-1.23.0.dist-info}/WHEEL +0 -0
@@ -1,23 +1,43 @@
1
+ import json
1
2
  from enum import StrEnum
2
- from typing import Any, Optional
3
+ from logging import getLogger
4
+ from pathlib import Path
5
+ from typing import Any, Generic, Optional, TypeVar, override
3
6
 
4
7
  from humps import camelize
5
- from pydantic import BaseModel, ConfigDict, Field
8
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
9
+ from pydantic_settings import BaseSettings
6
10
  from typing_extensions import deprecated
7
11
 
12
+ from unique_toolkit._common.exception import ConfigurationException
13
+ from unique_toolkit.app.unique_settings import UniqueChatEventFilterOptions
14
+ from unique_toolkit.smart_rules.compile import UniqueQL, parse_uniqueql
15
+
16
+ FilterOptionsT = TypeVar("FilterOptionsT", bound=BaseSettings)
17
+
8
18
  # set config to convert camelCase to snake_case
9
19
  model_config = ConfigDict(
10
20
  alias_generator=camelize,
11
21
  populate_by_name=True,
12
22
  arbitrary_types_allowed=True,
13
23
  )
24
+ _logger = getLogger(__name__)
14
25
 
15
26
 
16
27
  class EventName(StrEnum):
17
28
  EXTERNAL_MODULE_CHOSEN = "unique.chat.external-module.chosen"
18
-
19
-
20
- class BaseEvent(BaseModel):
29
+ USER_MESSAGE_CREATED = "unique.chat.user-message.created"
30
+ INGESTION_CONTENT_UPLOADED = "unique.ingestion.content.uploaded"
31
+ INGESTION_CONTENT_FINISHED = "unique.ingestion.content.finished"
32
+ MAGIC_TABLE_IMPORT_COLUMNS = "unique.magic-table.import-columns"
33
+ MAGIC_TABLE_ADD_META_DATA = "unique.magic-table.add-meta-data"
34
+ MAGIC_TABLE_ADD_DOCUMENT = "unique.magic-table.add-document"
35
+ MAGIC_TABLE_DELETE_ROW = "unique.magic-table.delete-row"
36
+ MAGIC_TABLE_DELETE_COLUMN = "unique.magic-table.delete-column"
37
+ MAGIC_TABLE_UPDATE_CELL = "unique.magic-table.update-cell"
38
+
39
+
40
+ class BaseEvent(BaseModel, Generic[FilterOptionsT]):
21
41
  model_config = model_config
22
42
 
23
43
  id: str
@@ -25,6 +45,68 @@ class BaseEvent(BaseModel):
25
45
  user_id: str
26
46
  company_id: str
27
47
 
48
+ @classmethod
49
+ def from_json_file(cls, file_path: Path) -> "BaseEvent":
50
+ if not file_path.exists():
51
+ raise FileNotFoundError(f"File not found: {file_path}")
52
+ with file_path.open("r", encoding="utf-8") as f:
53
+ data = json.load(f)
54
+ return cls.model_validate(data)
55
+
56
+ def filter_event(self, *, filter_options: FilterOptionsT | None = None) -> bool:
57
+ """Determine if event should be filtered out and be neglected."""
58
+ return False
59
+
60
+
61
+ ###
62
+ # MCP schemas
63
+ ###
64
+
65
+
66
+ class McpTool(BaseModel):
67
+ model_config = model_config
68
+
69
+ name: str
70
+ description: Optional[str] = None
71
+ input_schema: dict[str, Any]
72
+ output_schema: Optional[dict[str, Any]] = None
73
+ annotations: Optional[dict[str, Any]] = None
74
+ title: Optional[str] = Field(
75
+ default=None,
76
+ description="The display title for a tool. This is a Unique specific field.",
77
+ )
78
+ icon: Optional[str] = Field(
79
+ default=None,
80
+ description="An icon name from the Lucide icon set for the tool. This is a Unique specific field.",
81
+ )
82
+ system_prompt: Optional[str] = Field(
83
+ default=None,
84
+ description="An optional system prompt for the tool. This is a Unique specific field.",
85
+ )
86
+ user_prompt: Optional[str] = Field(
87
+ default=None,
88
+ description="An optional user prompt for the tool. This is a Unique specific field.",
89
+ )
90
+ is_connected: bool = Field(
91
+ description="Whether the tool is connected to the MCP server. This is a Unique specific field.",
92
+ )
93
+
94
+
95
+ class McpServer(BaseModel):
96
+ model_config = model_config
97
+
98
+ id: str
99
+ name: str
100
+ system_prompt: Optional[str] = Field(
101
+ default=None,
102
+ description="An optional system prompt for the MCP server.",
103
+ )
104
+ user_prompt: Optional[str] = Field(
105
+ default=None,
106
+ description="An optional user prompt for the MCP server.",
107
+ )
108
+ tools: list[McpTool] = []
109
+
28
110
 
29
111
  ###
30
112
  # ChatEvent schemas
@@ -95,45 +177,130 @@ class ChatEventPayload(BaseModel):
95
177
  assistant_id: str
96
178
  user_message: ChatEventUserMessage
97
179
  assistant_message: ChatEventAssistantMessage
98
- text: Optional[str] = None
99
- additional_parameters: Optional[ChatEventAdditionalParameters] = None
100
- user_metadata: Optional[dict[str, Any]] = None
101
- tool_choices: Optional[list[str]] = Field(
102
- default=[],
180
+ text: str | None = None
181
+ additional_parameters: ChatEventAdditionalParameters | None = None
182
+ user_metadata: dict[str, Any] | None = Field(
183
+ default_factory=dict,
184
+ )
185
+ tool_choices: list[str] = Field(
186
+ default_factory=list,
103
187
  description="A list containing the tool names the user has chosen to be activated.",
104
188
  )
105
- tool_parameters: Optional[dict[str, Any]] = None
106
- metadata_filter: Optional[dict[str, Any]] = None
189
+ disabled_tools: list[str] = Field(
190
+ default_factory=list,
191
+ description="A list containing the tool names of tools that are disabled at the company level",
192
+ )
193
+ tool_parameters: dict[str, Any] = Field(
194
+ default_factory=dict,
195
+ description="Parameters extracted from module selection function calling the tool.",
196
+ )
197
+ # Default is None as empty dict triggers error in `backend-ingestion`
198
+ metadata_filter: dict[str, Any] | None = Field(
199
+ default=None,
200
+ description="Metadata filter compiled after module selection function calling and scope rules.",
201
+ )
202
+ raw_scope_rules: UniqueQL | None = Field(
203
+ default=None,
204
+ description="Raw UniqueQL rule that can be compiled to a metadata filter.",
205
+ )
206
+ mcp_servers: list[McpServer] = Field(
207
+ default_factory=list,
208
+ description="A list of MCP servers with tools available for the chat session.",
209
+ )
210
+ message_execution_id: str | None = Field(
211
+ default=None,
212
+ description="The message execution id for triggering the chat event. Originates from the message execution service.",
213
+ )
214
+
215
+ @field_validator("raw_scope_rules", mode="before")
216
+ def validate_scope_rules(cls, value: dict[str, Any] | None) -> UniqueQL | None:
217
+ if value:
218
+ return parse_uniqueql(value)
107
219
 
108
220
 
109
221
  @deprecated("""Use `ChatEventPayload` instead.
110
222
  This class will be removed in the next major version.""")
111
223
  class EventPayload(ChatEventPayload):
112
- user_message: EventUserMessage
113
- assistant_message: EventAssistantMessage
114
- additional_parameters: Optional[EventAdditionalParameters] = None
224
+ pass
225
+ # user_message: EventUserMessage
226
+ # assistant_message: EventAssistantMessage
227
+ # additional_parameters: Optional[EventAdditionalParameters] = None
115
228
 
116
229
 
117
- @deprecated(
118
- """Use the more specific `ChatEvent` instead that has the same properties. \
119
- This class will be removed in the next major version."""
120
- )
121
- class Event(BaseModel):
230
+ class ChatEvent(BaseEvent):
122
231
  model_config = model_config
123
232
 
124
- id: str
125
- event: EventName
126
- user_id: str
127
- company_id: str
128
- payload: EventPayload
233
+ payload: ChatEventPayload
129
234
  created_at: Optional[int] = None
130
235
  version: Optional[str] = None
131
236
 
237
+ @classmethod
238
+ def from_json_file(cls, file_path: Path) -> "ChatEvent":
239
+ if not file_path.exists():
240
+ raise FileNotFoundError(f"File not found: {file_path}")
241
+ with file_path.open("r", encoding="utf-8") as f:
242
+ data = json.load(f)
243
+ return cls.model_validate(data)
244
+
245
+ def get_initial_debug_info(self) -> dict[str, Any]:
246
+ """Get the debug information for the chat event"""
247
+
248
+ # TODO: Make sure this coincides with what is shown in the first user message
249
+ return {
250
+ "user_metadata": self.payload.user_metadata,
251
+ "tool_parameters": self.payload.tool_parameters,
252
+ "chosen_module": self.payload.name,
253
+ "assistant": {"id": self.payload.assistant_id},
254
+ }
255
+
256
+ @override
257
+ def filter_event(
258
+ self, *, filter_options: UniqueChatEventFilterOptions | None = None
259
+ ) -> bool:
260
+ # Empty string evals to False
261
+
262
+ if filter_options is None:
263
+ return False # Don't filter when no options provided
264
+
265
+ if not filter_options.assistant_ids and not filter_options.references_in_code:
266
+ raise ConfigurationException(
267
+ "No filter options provided, all events will be filtered! \n"
268
+ "Please define: \n"
269
+ " - 'UNIQUE_CHAT_EVENT_FILTER_OPTIONS_ASSISTANT_IDS' \n"
270
+ " - 'UNIQUE_CHAT_EVENT_FILTER_OPTIONS_REFERENCES_IN_CODE' \n"
271
+ "in your environment variables."
272
+ )
273
+
274
+ # Per reference in code there can be multiple assistants
275
+ if (
276
+ filter_options.assistant_ids
277
+ and self.payload.assistant_id not in filter_options.assistant_ids
278
+ ):
279
+ return True
280
+
281
+ if (
282
+ filter_options.references_in_code
283
+ and self.payload.name not in filter_options.references_in_code
284
+ ):
285
+ return True
286
+
287
+ return super().filter_event(filter_options=filter_options)
132
288
 
133
- class ChatEvent(BaseEvent):
134
- model_config = model_config
135
289
 
136
- event: EventName
137
- payload: ChatEventPayload
138
- created_at: Optional[int] = None
139
- version: Optional[str] = None
290
+ @deprecated(
291
+ """Use the more specific `ChatEvent` instead that has the same properties. \
292
+ This class will be removed in the next major version."""
293
+ )
294
+ class Event(ChatEvent):
295
+ pass
296
+ # The below should only affect type hints
297
+ # event: EventName T
298
+ # payload: EventPayload
299
+
300
+ @classmethod
301
+ def from_json_file(cls, file_path: Path) -> "Event":
302
+ if not file_path.exists():
303
+ raise FileNotFoundError(f"File not found: {file_path}")
304
+ with file_path.open("r", encoding="utf-8") as f:
305
+ data = json.load(f)
306
+ return cls.model_validate(data)
@@ -0,0 +1,367 @@
1
+ import os
2
+ from logging import getLogger
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING, Self, TypeVar
5
+ from urllib.parse import ParseResult, urlparse, urlunparse
6
+
7
+ import unique_sdk
8
+ from platformdirs import user_config_dir
9
+ from pydantic import AliasChoices, Field, SecretStr, model_validator
10
+ from pydantic_settings import BaseSettings, SettingsConfigDict
11
+
12
+ if TYPE_CHECKING:
13
+ from unique_toolkit.app.schemas import BaseEvent
14
+
15
+
16
+ logger = getLogger(__name__)
17
+
18
+ T = TypeVar("T", bound=BaseSettings)
19
+
20
+
21
+ def warn_about_defaults(instance: T) -> T:
22
+ """Log warnings for fields that are using default values."""
23
+ for field_name, model_field in instance.__class__.model_fields.items():
24
+ field_value = getattr(instance, field_name)
25
+ default_value = model_field.default
26
+
27
+ # Handle SecretStr comparison by comparing the secret values
28
+ if isinstance(field_value, SecretStr) and isinstance(default_value, SecretStr):
29
+ if field_value.get_secret_value() == default_value.get_secret_value():
30
+ logger.warning(
31
+ f"Using default value for '{field_name}': {default_value.get_secret_value()}"
32
+ )
33
+ elif field_value == default_value:
34
+ logger.warning(f"Using default value for '{field_name}': {default_value}")
35
+ return instance
36
+
37
+
38
+ class UniqueApp(BaseSettings):
39
+ id: SecretStr = Field(
40
+ default=SecretStr("dummy_id"),
41
+ validation_alias=AliasChoices(
42
+ "unique_app_id", "app_id", "UNIQUE_APP_ID", "APP_ID"
43
+ ),
44
+ )
45
+ key: SecretStr = Field(
46
+ default=SecretStr("dummy_key"),
47
+ validation_alias=AliasChoices(
48
+ "unique_app_key", "key", "UNIQUE_APP_KEY", "KEY", "API_KEY", "api_key"
49
+ ),
50
+ )
51
+ base_url: str = Field(
52
+ default="http://localhost:8092/",
53
+ deprecated="Use UniqueApi.base_url instead",
54
+ )
55
+ endpoint: str = Field(default="dummy")
56
+
57
+ endpoint_secret: SecretStr = Field(default=SecretStr("dummy_secret"))
58
+
59
+ @model_validator(mode="after")
60
+ def _warn_about_defaults(self) -> Self:
61
+ return warn_about_defaults(self)
62
+
63
+ model_config = SettingsConfigDict(
64
+ env_prefix="unique_app_",
65
+ env_file_encoding="utf-8",
66
+ case_sensitive=False,
67
+ extra="ignore",
68
+ )
69
+
70
+
71
+ class UniqueApi(BaseSettings):
72
+ base_url: str = Field(
73
+ default="http://localhost:8092/",
74
+ description="The base URL of the Unique API. Ask your admin to provide you with the correct URL.",
75
+ validation_alias=AliasChoices(
76
+ "unique_api_base_url",
77
+ "base_url",
78
+ "UNIQUE_API_BASE_URL",
79
+ "BASE_URL",
80
+ "API_BASE",
81
+ ),
82
+ )
83
+ version: str = Field(
84
+ default="2023-12-06",
85
+ validation_alias=AliasChoices(
86
+ "unique_api_version", "version", "UNIQUE_API_VERSION", "VERSION"
87
+ ),
88
+ )
89
+
90
+ model_config = SettingsConfigDict(
91
+ env_prefix="unique_api_",
92
+ env_file_encoding="utf-8",
93
+ case_sensitive=False,
94
+ extra="ignore",
95
+ )
96
+
97
+ @model_validator(mode="after")
98
+ def _warn_about_defaults(self) -> Self:
99
+ return warn_about_defaults(self)
100
+
101
+ def sse_url(self, subscriptions: list[str]) -> str:
102
+ parsed = urlparse(self.base_url)
103
+ return urlunparse(
104
+ parsed._replace(
105
+ path="/public/event-socket/events/stream",
106
+ query=f"subscriptions={','.join(subscriptions)}",
107
+ fragment=None,
108
+ )
109
+ )
110
+
111
+ def base_path(self) -> tuple[ParseResult, str]:
112
+ parsed = urlparse(self.base_url)
113
+ base_path = "/public/chat/"
114
+
115
+ if parsed.hostname and (
116
+ "gateway.qa.unique" in parsed.hostname
117
+ or "gateway.unique" in parsed.hostname
118
+ ):
119
+ base_path = "/public/chat-gen2/"
120
+
121
+ if parsed.hostname and (
122
+ "localhost" in parsed.hostname or "svc.cluster.local" in parsed.hostname
123
+ ):
124
+ base_path = "/public/"
125
+
126
+ return parsed, base_path
127
+
128
+ def sdk_url(self) -> str:
129
+ parsed, base_path = self.base_path()
130
+ return urlunparse(parsed._replace(path=base_path, query=None, fragment=None))
131
+
132
+ def openai_proxy_url(self) -> str:
133
+ parsed, base_path = self.base_path()
134
+ path = base_path + "openai-proxy/"
135
+ return urlunparse(parsed._replace(path=path, query=None, fragment=None))
136
+
137
+
138
+ class UniqueAuth(BaseSettings):
139
+ company_id: SecretStr = Field(
140
+ default=SecretStr("dummy_company_id"),
141
+ validation_alias=AliasChoices(
142
+ "unique_auth_company_id",
143
+ "company_id",
144
+ "UNIQUE_AUTH_COMPANY_ID",
145
+ "COMPANY_ID",
146
+ ),
147
+ )
148
+ user_id: SecretStr = Field(
149
+ default=SecretStr("dummy_user_id"),
150
+ validation_alias=AliasChoices(
151
+ "unique_auth_user_id", "user_id", "UNIQUE_AUTH_USER_ID", "USER_ID"
152
+ ),
153
+ )
154
+
155
+ model_config = SettingsConfigDict(
156
+ env_prefix="unique_auth_",
157
+ env_file_encoding="utf-8",
158
+ case_sensitive=False,
159
+ extra="ignore",
160
+ )
161
+
162
+ @model_validator(mode="after")
163
+ def _warn_about_defaults(self) -> Self:
164
+ return warn_about_defaults(self)
165
+
166
+ @classmethod
167
+ def from_event(cls, event: "BaseEvent") -> Self:
168
+ return cls(
169
+ company_id=SecretStr(event.company_id),
170
+ user_id=SecretStr(event.user_id),
171
+ )
172
+
173
+
174
+ class UniqueChatEventFilterOptions(BaseSettings):
175
+ # Empty string evals to False
176
+ assistant_ids: list[str] = Field(
177
+ default=[],
178
+ description="The assistant ids (space) to filter by. Default is all assistants.",
179
+ )
180
+ references_in_code: list[str] = Field(
181
+ default=[],
182
+ description="The module (reference) names in code to filter by. Default is all modules.",
183
+ )
184
+
185
+ model_config = SettingsConfigDict(
186
+ env_prefix="unique_chat_event_filter_options_",
187
+ env_file_encoding="utf-8",
188
+ case_sensitive=False,
189
+ extra="ignore",
190
+ )
191
+
192
+ @model_validator(mode="after")
193
+ def _warn_about_defaults(self) -> Self:
194
+ return warn_about_defaults(self)
195
+
196
+
197
+ class EnvFileNotFoundError(FileNotFoundError):
198
+ """Raised when no environment file can be found in any of the expected locations."""
199
+
200
+
201
+ class UniqueSettings:
202
+ def __init__(
203
+ self,
204
+ auth: UniqueAuth,
205
+ app: UniqueApp,
206
+ api: UniqueApi,
207
+ *,
208
+ chat_event_filter_options: UniqueChatEventFilterOptions | None = None,
209
+ env_file: Path | None = None,
210
+ ):
211
+ self._app = app
212
+ self._auth = auth
213
+ self._api = api
214
+ self._chat_event_filter_options = chat_event_filter_options
215
+ self._env_file: Path | None = (
216
+ env_file if (env_file and env_file.exists()) else None
217
+ )
218
+
219
+ @classmethod
220
+ def _find_env_file(cls, filename: str = "unique.env") -> Path:
221
+ """Find environment file using cross-platform fallback locations.
222
+
223
+ Search order:
224
+ 1. UNIQUE_ENV_FILE environment variable
225
+ 2. Current working directory
226
+ 3. User config directory (cross-platform via platformdirs)
227
+
228
+ Args:
229
+ filename: Name of the environment file (default: 'unique.env')
230
+
231
+ Returns:
232
+ Path to the environment file.
233
+
234
+ Raises:
235
+ EnvFileNotFoundError: If no environment file is found in any location.
236
+ """
237
+ locations = [
238
+ # 1. Explicit environment variable
239
+ Path(env_path) if (env_path := os.environ.get("UNIQUE_ENV_FILE")) else None,
240
+ # 2. Current working directory
241
+ Path.cwd() / filename,
242
+ # 3. User config directory (cross-platform)
243
+ Path(user_config_dir("unique", "unique-toolkit")) / filename,
244
+ ]
245
+
246
+ for location in locations:
247
+ if location and location.exists() and location.is_file():
248
+ return location
249
+
250
+ # If no file found, provide helpful error message
251
+ searched_locations = [str(loc) for loc in locations if loc is not None]
252
+ raise EnvFileNotFoundError(
253
+ f"Environment file '{filename}' not found. Searched locations:\n"
254
+ + "\n".join(f" - {loc}" for loc in searched_locations)
255
+ + "\n\nTo fix this:\n"
256
+ + f" 1. Create {filename} in one of the above locations, or\n"
257
+ + f" 2. Set UNIQUE_ENV_FILE environment variable to point to your {filename} file"
258
+ )
259
+
260
+ @classmethod
261
+ def from_env(
262
+ cls,
263
+ env_file: Path | None = None,
264
+ ) -> "UniqueSettings":
265
+ """Initialize settings from environment variables and/or env file.
266
+
267
+ Args:
268
+ env_file: Optional path to environment file. If provided, will load variables from this file.
269
+
270
+ Returns:
271
+ UniqueSettings instance with values loaded from environment/env file.
272
+
273
+ Raises:
274
+ FileNotFoundError: If env_file is provided but does not exist.
275
+ ValidationError: If required environment variables are missing.
276
+ """
277
+ if env_file and not env_file.exists():
278
+ raise FileNotFoundError(f"Environment file not found: {env_file}")
279
+
280
+ # Initialize settings with environment file if provided
281
+ env_file_str = str(env_file) if env_file else None
282
+ auth = UniqueAuth(_env_file=env_file_str) # type: ignore[call-arg]
283
+ app = UniqueApp(_env_file=env_file_str) # type: ignore[call-arg]
284
+ api = UniqueApi(_env_file=env_file_str) # type: ignore[call-arg]
285
+ event_filter_options = UniqueChatEventFilterOptions(_env_file=env_file_str) # type: ignore[call-arg]
286
+ return cls(
287
+ auth=auth,
288
+ app=app,
289
+ api=api,
290
+ chat_event_filter_options=event_filter_options,
291
+ env_file=env_file,
292
+ )
293
+
294
+ @classmethod
295
+ def from_env_auto(cls, filename: str = "unique.env") -> "UniqueSettings":
296
+ """Initialize settings by automatically finding environment file.
297
+
298
+ This method will automatically search for an environment file in standard locations
299
+ and fall back to environment variables only if no file is found.
300
+
301
+ Args:
302
+ filename: Name of the environment file to search for (default: '.env')
303
+
304
+ Returns:
305
+ UniqueSettings instance with values loaded from found env file or environment variables.
306
+ """
307
+ try:
308
+ env_file = cls._find_env_file(filename)
309
+ logger.info(f"Environment file found at {env_file}")
310
+ return cls.from_env(env_file=env_file)
311
+ except EnvFileNotFoundError:
312
+ logger.warning(
313
+ f"Environment file '{filename}' not found. Falling back to environment variables only."
314
+ )
315
+ # Fall back to environment variables only
316
+ return cls.from_env()
317
+
318
+ def init_sdk(self) -> None:
319
+ """Initialize the unique_sdk global configuration with these settings.
320
+
321
+ This method configures the global unique_sdk module with the API key,
322
+ app ID, and base URL from these settings.
323
+ """
324
+ unique_sdk.api_key = self._app.key.get_secret_value()
325
+ unique_sdk.app_id = self._app.id.get_secret_value()
326
+ unique_sdk.api_base = self._api.sdk_url()
327
+
328
+ @classmethod
329
+ def from_env_auto_with_sdk_init(
330
+ cls, filename: str = "unique.env"
331
+ ) -> "UniqueSettings":
332
+ """Initialize settings and SDK in one convenient call.
333
+
334
+ This method combines from_env_auto() and init_sdk() for the most common use case.
335
+
336
+ Args:
337
+ filename: Name of the environment file to search for (default: '.env')
338
+
339
+ Returns:
340
+ UniqueSettings instance with SDK already initialized.
341
+ """
342
+ settings = cls.from_env_auto(filename)
343
+ settings.init_sdk()
344
+ return settings
345
+
346
+ def update_from_event(self, event: "BaseEvent") -> None:
347
+ self._auth = UniqueAuth.from_event(event)
348
+
349
+ @property
350
+ def api(self) -> UniqueApi:
351
+ return self._api
352
+
353
+ @property
354
+ def app(self) -> UniqueApp:
355
+ return self._app
356
+
357
+ @property
358
+ def auth(self) -> UniqueAuth:
359
+ return self._auth
360
+
361
+ @auth.setter
362
+ def auth(self, value: UniqueAuth) -> None:
363
+ self._auth = value
364
+
365
+ @property
366
+ def chat_event_filter_options(self) -> UniqueChatEventFilterOptions | None:
367
+ return self._chat_event_filter_options
@@ -1,3 +1,5 @@
1
+ import warnings
2
+
1
3
  from .constants import DOMAIN_NAME as DOMAIN_NAME
2
4
  from .schemas import ChatMessage as ChatMessage
3
5
  from .schemas import ChatMessageAssessment as ChatMessageAssessment
@@ -5,7 +7,12 @@ from .schemas import ChatMessageAssessmentLabel as ChatMessageAssessmentLabel
5
7
  from .schemas import ChatMessageAssessmentStatus as ChatMessageAssessmentStatus
6
8
  from .schemas import ChatMessageAssessmentType as ChatMessageAssessmentType
7
9
  from .schemas import ChatMessageRole as ChatMessageRole
8
- from .service import ChatService as ChatService
10
+
11
+ # Import ChatService with deprecation warning suppressed for internal use
12
+ with warnings.catch_warnings():
13
+ warnings.simplefilter("ignore", DeprecationWarning)
14
+ from .service import ChatService as ChatService
15
+
9
16
  from .utils import (
10
17
  convert_chat_history_to_injectable_string as convert_chat_history_to_injectable_string,
11
18
  )