unique_toolkit 1.28.8__py3-none-any.whl → 1.33.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- unique_toolkit/__init__.py +12 -6
- unique_toolkit/_common/docx_generator/service.py +8 -32
- unique_toolkit/_common/utils/jinja/helpers.py +10 -0
- unique_toolkit/_common/utils/jinja/render.py +18 -0
- unique_toolkit/_common/utils/jinja/schema.py +65 -0
- unique_toolkit/_common/utils/jinja/utils.py +80 -0
- unique_toolkit/agentic/message_log_manager/service.py +9 -0
- unique_toolkit/agentic/tools/a2a/postprocessing/_display_utils.py +58 -3
- unique_toolkit/agentic/tools/a2a/postprocessing/_ref_utils.py +11 -0
- unique_toolkit/agentic/tools/a2a/postprocessing/config.py +33 -0
- unique_toolkit/agentic/tools/a2a/postprocessing/display.py +99 -15
- unique_toolkit/agentic/tools/a2a/postprocessing/test/test_display.py +421 -0
- unique_toolkit/agentic/tools/a2a/postprocessing/test/test_display_utils.py +768 -0
- unique_toolkit/agentic/tools/a2a/tool/config.py +77 -1
- unique_toolkit/agentic/tools/a2a/tool/service.py +67 -3
- unique_toolkit/agentic/tools/config.py +5 -45
- unique_toolkit/agentic/tools/openai_builtin/base.py +4 -0
- unique_toolkit/agentic/tools/openai_builtin/code_interpreter/service.py +4 -0
- unique_toolkit/agentic/tools/tool_manager.py +16 -19
- unique_toolkit/app/__init__.py +3 -0
- unique_toolkit/app/fast_api_factory.py +131 -0
- unique_toolkit/app/webhook.py +77 -0
- unique_toolkit/chat/functions.py +1 -1
- unique_toolkit/content/functions.py +4 -4
- unique_toolkit/content/service.py +1 -1
- unique_toolkit/data_extraction/README.md +96 -0
- unique_toolkit/data_extraction/__init__.py +11 -0
- unique_toolkit/data_extraction/augmented/__init__.py +5 -0
- unique_toolkit/data_extraction/augmented/service.py +93 -0
- unique_toolkit/data_extraction/base.py +25 -0
- unique_toolkit/data_extraction/basic/__init__.py +11 -0
- unique_toolkit/data_extraction/basic/config.py +18 -0
- unique_toolkit/data_extraction/basic/prompt.py +13 -0
- unique_toolkit/data_extraction/basic/service.py +55 -0
- unique_toolkit/embedding/service.py +1 -1
- unique_toolkit/framework_utilities/langchain/__init__.py +10 -0
- unique_toolkit/framework_utilities/openai/client.py +2 -1
- unique_toolkit/language_model/infos.py +22 -1
- unique_toolkit/services/knowledge_base.py +4 -6
- {unique_toolkit-1.28.8.dist-info → unique_toolkit-1.33.3.dist-info}/METADATA +51 -2
- {unique_toolkit-1.28.8.dist-info → unique_toolkit-1.33.3.dist-info}/RECORD +43 -27
- unique_toolkit/agentic/tools/test/test_tool_manager.py +0 -1686
- {unique_toolkit-1.28.8.dist-info → unique_toolkit-1.33.3.dist-info}/LICENSE +0 -0
- {unique_toolkit-1.28.8.dist-info → unique_toolkit-1.33.3.dist-info}/WHEEL +0 -0
|
@@ -1,10 +1,74 @@
|
|
|
1
|
-
|
|
1
|
+
import re
|
|
2
|
+
from enum import StrEnum
|
|
3
|
+
from typing import Annotated, Generic, Literal, TypeVar
|
|
2
4
|
|
|
3
5
|
from pydantic import Field
|
|
6
|
+
from pydantic.main import BaseModel
|
|
4
7
|
|
|
5
8
|
from unique_toolkit._common.pydantic_helpers import get_configuration_dict
|
|
6
9
|
from unique_toolkit.agentic.tools.schemas import BaseToolConfig
|
|
7
10
|
|
|
11
|
+
|
|
12
|
+
class SubAgentSystemReminderType(StrEnum):
|
|
13
|
+
FIXED = "fixed"
|
|
14
|
+
REGEXP = "regexp"
|
|
15
|
+
REFERENCE = "reference"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T", bound=SubAgentSystemReminderType)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SystemReminderConfig(BaseModel, Generic[T]):
|
|
22
|
+
model_config = get_configuration_dict()
|
|
23
|
+
|
|
24
|
+
type: T
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_SYSTEM_REMINDER_FIELD_DESCRIPTION = """
|
|
28
|
+
The reminder to add to the tool response. The reminder can be a Jinja template and can contain the following placeholders:
|
|
29
|
+
- {{ display_name }}: The display name of the sub agent.
|
|
30
|
+
- {{ tool_name }}: The tool name.
|
|
31
|
+
""".strip()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ReferenceSystemReminderConfig(SystemReminderConfig):
|
|
35
|
+
type: Literal[SubAgentSystemReminderType.REFERENCE] = (
|
|
36
|
+
SubAgentSystemReminderType.REFERENCE
|
|
37
|
+
)
|
|
38
|
+
reminder: str = Field(
|
|
39
|
+
default="Rememeber to properly reference EACH fact from sub agent {{ display_name }}'s response with the correct format INLINE.",
|
|
40
|
+
description=_SYSTEM_REMINDER_FIELD_DESCRIPTION,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class FixedSystemReminderConfig(SystemReminderConfig):
|
|
45
|
+
type: Literal[SubAgentSystemReminderType.FIXED] = SubAgentSystemReminderType.FIXED
|
|
46
|
+
reminder: str = Field(
|
|
47
|
+
description=_SYSTEM_REMINDER_FIELD_DESCRIPTION,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_REGEXP_DETECTED_REMINDER_FIELD_DESCRIPTION = """
|
|
52
|
+
The reminder to add to the tool response. The reminder can be a Jinja template and can contain the following placeholders:
|
|
53
|
+
- {{ display_name }}: The display name of the sub agent.
|
|
54
|
+
- {{ tool_name }}: The tool name.
|
|
55
|
+
- {{ text_matches }}: Will be replaced with the portions of the text that triggered the reminder.
|
|
56
|
+
""".strip()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RegExpDetectedSystemReminderConfig(SystemReminderConfig):
|
|
60
|
+
"""A system reminder that is only added if the sub agent response matches a regular expression."""
|
|
61
|
+
|
|
62
|
+
type: Literal[SubAgentSystemReminderType.REGEXP] = SubAgentSystemReminderType.REGEXP
|
|
63
|
+
|
|
64
|
+
regexp: re.Pattern[str] = Field(
|
|
65
|
+
description="The regular expression to use to detect whether the system reminder should be added.",
|
|
66
|
+
)
|
|
67
|
+
reminder: str = Field(
|
|
68
|
+
description=_REGEXP_DETECTED_REMINDER_FIELD_DESCRIPTION,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
8
72
|
DEFAULT_PARAM_DESCRIPTION_SUB_AGENT_USER_MESSAGE = """
|
|
9
73
|
This is the message that will be sent to the sub-agent.
|
|
10
74
|
""".strip()
|
|
@@ -80,3 +144,15 @@ class SubAgentToolConfig(BaseToolConfig):
|
|
|
80
144
|
default=False,
|
|
81
145
|
description="If set, the sub-agent response will be interpreted as a list of content chunks.",
|
|
82
146
|
)
|
|
147
|
+
|
|
148
|
+
system_reminders_config: list[
|
|
149
|
+
Annotated[
|
|
150
|
+
FixedSystemReminderConfig
|
|
151
|
+
| RegExpDetectedSystemReminderConfig
|
|
152
|
+
| ReferenceSystemReminderConfig,
|
|
153
|
+
Field(discriminator="type"),
|
|
154
|
+
]
|
|
155
|
+
] = Field(
|
|
156
|
+
default=[],
|
|
157
|
+
description="Configuration for the system reminders to add to the tool response.",
|
|
158
|
+
)
|
|
@@ -4,7 +4,7 @@ import json
|
|
|
4
4
|
import logging
|
|
5
5
|
import re
|
|
6
6
|
from datetime import datetime
|
|
7
|
-
from typing import override
|
|
7
|
+
from typing import cast, override
|
|
8
8
|
|
|
9
9
|
import unique_sdk
|
|
10
10
|
from pydantic import Field, TypeAdapter, create_model
|
|
@@ -15,6 +15,7 @@ from unique_toolkit._common.referencing import (
|
|
|
15
15
|
remove_all_refs,
|
|
16
16
|
replace_ref_number,
|
|
17
17
|
)
|
|
18
|
+
from unique_toolkit._common.utils.jinja.render import render_template
|
|
18
19
|
from unique_toolkit.agentic.evaluation.schemas import EvaluationMetricName
|
|
19
20
|
from unique_toolkit.agentic.tools.a2a.response_watcher import SubAgentResponseWatcher
|
|
20
21
|
from unique_toolkit.agentic.tools.a2a.tool._memory import (
|
|
@@ -25,6 +26,8 @@ from unique_toolkit.agentic.tools.a2a.tool._schema import (
|
|
|
25
26
|
SubAgentToolInput,
|
|
26
27
|
)
|
|
27
28
|
from unique_toolkit.agentic.tools.a2a.tool.config import (
|
|
29
|
+
RegExpDetectedSystemReminderConfig,
|
|
30
|
+
SubAgentSystemReminderType,
|
|
28
31
|
SubAgentToolConfig,
|
|
29
32
|
)
|
|
30
33
|
from unique_toolkit.agentic.tools.factory import ToolFactory
|
|
@@ -215,12 +218,63 @@ class SubAgentTool(Tool[SubAgentToolConfig]):
|
|
|
215
218
|
)
|
|
216
219
|
|
|
217
220
|
return ToolCallResponse(
|
|
218
|
-
id=tool_call.id,
|
|
221
|
+
id=tool_call.id,
|
|
219
222
|
name=tool_call.name,
|
|
220
|
-
content=
|
|
223
|
+
content=_format_response(
|
|
224
|
+
tool_name=self.name,
|
|
225
|
+
text=content,
|
|
226
|
+
system_reminders=self._get_system_reminders(response),
|
|
227
|
+
),
|
|
221
228
|
content_chunks=content_chunks,
|
|
222
229
|
)
|
|
223
230
|
|
|
231
|
+
def _get_system_reminders(self, message: unique_sdk.Space.Message) -> list[str]:
|
|
232
|
+
reminders = []
|
|
233
|
+
for reminder_config in self.config.system_reminders_config:
|
|
234
|
+
if reminder_config.type == SubAgentSystemReminderType.FIXED:
|
|
235
|
+
reminders.append(
|
|
236
|
+
render_template(
|
|
237
|
+
reminder_config.reminder,
|
|
238
|
+
display_name=self.display_name(),
|
|
239
|
+
tool_name=self.name,
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
elif (
|
|
243
|
+
reminder_config.type == SubAgentSystemReminderType.REFERENCE
|
|
244
|
+
and self.config.use_sub_agent_references
|
|
245
|
+
and message["references"] is not None
|
|
246
|
+
and len(message["references"]) > 0
|
|
247
|
+
):
|
|
248
|
+
reminders.append(
|
|
249
|
+
render_template(
|
|
250
|
+
reminder_config.reminder,
|
|
251
|
+
display_name=self.display_name(),
|
|
252
|
+
tool_name=self.name,
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
elif (
|
|
256
|
+
reminder_config.type == SubAgentSystemReminderType.REGEXP
|
|
257
|
+
and message["text"] is not None
|
|
258
|
+
):
|
|
259
|
+
reminder_config = cast(
|
|
260
|
+
RegExpDetectedSystemReminderConfig, reminder_config
|
|
261
|
+
)
|
|
262
|
+
text_matches = [
|
|
263
|
+
match.group(0)
|
|
264
|
+
for match in reminder_config.regexp.finditer(message["text"])
|
|
265
|
+
]
|
|
266
|
+
if len(text_matches) > 0:
|
|
267
|
+
reminders.append(
|
|
268
|
+
render_template(
|
|
269
|
+
reminder_config.reminder,
|
|
270
|
+
display_name=self.display_name(),
|
|
271
|
+
tool_name=self.name,
|
|
272
|
+
text_matches=text_matches,
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
return reminders
|
|
277
|
+
|
|
224
278
|
async def _get_chat_id(self) -> str | None:
|
|
225
279
|
if not self.config.reuse_chat:
|
|
226
280
|
return None
|
|
@@ -326,4 +380,14 @@ class SubAgentTool(Tool[SubAgentToolConfig]):
|
|
|
326
380
|
) from e
|
|
327
381
|
|
|
328
382
|
|
|
383
|
+
def _format_response(tool_name: str, text: str, system_reminders: list[str]) -> str:
|
|
384
|
+
if len(system_reminders) == 0:
|
|
385
|
+
return text
|
|
386
|
+
|
|
387
|
+
reponse_key = f"{tool_name} response"
|
|
388
|
+
response = {reponse_key: text, "SYSTEM_REMINDERS": system_reminders}
|
|
389
|
+
|
|
390
|
+
return json.dumps(response, indent=2)
|
|
391
|
+
|
|
392
|
+
|
|
329
393
|
ToolFactory.register_tool(SubAgentTool, SubAgentToolConfig)
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import json
|
|
2
1
|
from enum import StrEnum
|
|
3
|
-
from typing import Annotated, Any
|
|
2
|
+
from typing import Annotated, Any
|
|
4
3
|
|
|
5
4
|
from pydantic import (
|
|
6
5
|
BaseModel,
|
|
7
6
|
BeforeValidator,
|
|
8
7
|
Field,
|
|
9
8
|
ValidationInfo,
|
|
9
|
+
field_serializer,
|
|
10
10
|
model_validator,
|
|
11
11
|
)
|
|
12
12
|
|
|
@@ -123,46 +123,6 @@ class ToolBuildConfig(BaseModel):
|
|
|
123
123
|
value["configuration"] = config
|
|
124
124
|
return value
|
|
125
125
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
subclass fields from `configuration` by delegating to its own
|
|
130
|
-
model_dump. This prevents `{}` when `configuration` is typed
|
|
131
|
-
as `BaseToolConfig` but holds a subclass instance.
|
|
132
|
-
"""
|
|
133
|
-
data: Dict[str, Any] = {
|
|
134
|
-
"name": self.name,
|
|
135
|
-
"configuration": self.configuration.model_dump()
|
|
136
|
-
if self.configuration
|
|
137
|
-
else None,
|
|
138
|
-
"display_name": self.display_name,
|
|
139
|
-
"icon": self.icon,
|
|
140
|
-
"selection_policy": self.selection_policy,
|
|
141
|
-
"is_exclusive": self.is_exclusive,
|
|
142
|
-
"is_sub_agent": self.is_sub_agent,
|
|
143
|
-
"is_enabled": self.is_enabled,
|
|
144
|
-
}
|
|
145
|
-
return data
|
|
146
|
-
|
|
147
|
-
def model_dump_json(self) -> str:
|
|
148
|
-
"""
|
|
149
|
-
Returns a JSON string representation of the tool config.
|
|
150
|
-
Ensures `configuration` is fully serialized by using the
|
|
151
|
-
subclass's `model_dump_json()` when available.
|
|
152
|
-
"""
|
|
153
|
-
config_json = (
|
|
154
|
-
self.configuration.model_dump_json() if self.configuration else None
|
|
155
|
-
)
|
|
156
|
-
config = json.loads(config_json) if config_json else None
|
|
157
|
-
|
|
158
|
-
data: Dict[str, Any] = {
|
|
159
|
-
"name": self.name,
|
|
160
|
-
"configuration": config,
|
|
161
|
-
"display_name": self.display_name,
|
|
162
|
-
"icon": self.icon,
|
|
163
|
-
"selection_policy": self.selection_policy,
|
|
164
|
-
"is_exclusive": self.is_exclusive,
|
|
165
|
-
"is_sub_agent": self.is_sub_agent,
|
|
166
|
-
"is_enabled": self.is_enabled,
|
|
167
|
-
}
|
|
168
|
-
return json.dumps(data)
|
|
126
|
+
@field_serializer("configuration")
|
|
127
|
+
def serialize_config(self, value: BaseToolConfig) -> dict[str, Any]:
|
|
128
|
+
return value.__class__.model_dump(value)
|
|
@@ -244,3 +244,7 @@ class OpenAICodeInterpreterTool(OpenAIBuiltInTool[CodeInterpreter]):
|
|
|
244
244
|
tool_format_information_for_user_prompt=self._config.tool_format_information_for_user_prompt,
|
|
245
245
|
input_model={},
|
|
246
246
|
)
|
|
247
|
+
|
|
248
|
+
@override
|
|
249
|
+
def display_name(self) -> str:
|
|
250
|
+
return self.DISPLAY_NAME
|
|
@@ -231,25 +231,6 @@ class _ToolManager(Generic[_ApiMode]):
|
|
|
231
231
|
self,
|
|
232
232
|
tool_calls: list[LanguageModelFunction],
|
|
233
233
|
) -> list[ToolCallResponse]:
|
|
234
|
-
tool_calls = tool_calls
|
|
235
|
-
|
|
236
|
-
tool_calls = self.filter_duplicate_tool_calls(
|
|
237
|
-
tool_calls=tool_calls,
|
|
238
|
-
)
|
|
239
|
-
num_tool_calls = len(tool_calls)
|
|
240
|
-
|
|
241
|
-
if num_tool_calls > self._config.max_tool_calls:
|
|
242
|
-
self._logger.warning(
|
|
243
|
-
(
|
|
244
|
-
"Number of tool calls %s exceeds the allowed maximum of %s."
|
|
245
|
-
"The tool calls will be reduced to the first %s."
|
|
246
|
-
),
|
|
247
|
-
num_tool_calls,
|
|
248
|
-
self._config.max_tool_calls,
|
|
249
|
-
self._config.max_tool_calls,
|
|
250
|
-
)
|
|
251
|
-
tool_calls = tool_calls[: self._config.max_tool_calls]
|
|
252
|
-
|
|
253
234
|
tool_call_responses = await self._execute_parallelized(tool_calls)
|
|
254
235
|
return tool_call_responses
|
|
255
236
|
|
|
@@ -358,6 +339,22 @@ class _ToolManager(Generic[_ApiMode]):
|
|
|
358
339
|
)
|
|
359
340
|
return unique_tool_calls
|
|
360
341
|
|
|
342
|
+
def filter_tool_calls_by_max_tool_calls_allowed(
|
|
343
|
+
self, tool_calls: list[LanguageModelFunction]
|
|
344
|
+
) -> list[LanguageModelFunction]:
|
|
345
|
+
if len(tool_calls) > self._config.max_tool_calls:
|
|
346
|
+
self._logger.warning(
|
|
347
|
+
(
|
|
348
|
+
"Number of tool calls %s exceeds the allowed maximum of %s."
|
|
349
|
+
"The tool calls will be reduced to the first %s."
|
|
350
|
+
),
|
|
351
|
+
len(tool_calls),
|
|
352
|
+
self._config.max_tool_calls,
|
|
353
|
+
self._config.max_tool_calls,
|
|
354
|
+
)
|
|
355
|
+
return tool_calls[: self._config.max_tool_calls]
|
|
356
|
+
return tool_calls
|
|
357
|
+
|
|
361
358
|
@overload
|
|
362
359
|
def get_tool_by_name(
|
|
363
360
|
self: "_ToolManager[Literal['completions']]", name: str
|
unique_toolkit/app/__init__.py
CHANGED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from logging import getLogger
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Callable, TypeVar
|
|
4
|
+
|
|
5
|
+
from pydantic import ValidationError
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from fastapi import BackgroundTasks, FastAPI, Request, status
|
|
9
|
+
from fastapi.responses import JSONResponse
|
|
10
|
+
else:
|
|
11
|
+
try:
|
|
12
|
+
from fastapi import BackgroundTasks, FastAPI, Request, status
|
|
13
|
+
from fastapi.responses import JSONResponse
|
|
14
|
+
except ImportError:
|
|
15
|
+
FastAPI = None # type: ignore[assignment, misc]
|
|
16
|
+
Request = None # type: ignore[assignment, misc]
|
|
17
|
+
status = None # type: ignore[assignment, misc]
|
|
18
|
+
JSONResponse = None # type: ignore[assignment, misc]
|
|
19
|
+
BackgroundTasks = None # type: ignore[assignment, misc]
|
|
20
|
+
|
|
21
|
+
from unique_toolkit.app.schemas import BaseEvent, ChatEvent, EventName
|
|
22
|
+
from unique_toolkit.app.unique_settings import UniqueSettings
|
|
23
|
+
|
|
24
|
+
logger = getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def default_event_handler(event: Any) -> int:
|
|
28
|
+
logger.info("Event received at event handler")
|
|
29
|
+
if status is not None:
|
|
30
|
+
return status.HTTP_200_OK
|
|
31
|
+
else:
|
|
32
|
+
# No fastapi installed
|
|
33
|
+
return 200
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
T = TypeVar("T", bound=BaseEvent)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_unique_custom_app(
|
|
40
|
+
*,
|
|
41
|
+
title: str = "Unique Chat App",
|
|
42
|
+
webhook_path: str = "/webhook",
|
|
43
|
+
settings: UniqueSettings,
|
|
44
|
+
event_handler: Callable[[T], int] = default_event_handler,
|
|
45
|
+
event_constructor: Callable[..., T] = ChatEvent,
|
|
46
|
+
subscribed_event_names: list[str] | None = None,
|
|
47
|
+
) -> "FastAPI":
|
|
48
|
+
"""Factory class for creating FastAPI apps with Unique webhook handling."""
|
|
49
|
+
if FastAPI is None:
|
|
50
|
+
raise ImportError(
|
|
51
|
+
"FastAPI is not installed. Install it with: poetry install --with fastapi"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
app = FastAPI(title=title)
|
|
55
|
+
|
|
56
|
+
if subscribed_event_names is None:
|
|
57
|
+
subscribed_event_names = [EventName.EXTERNAL_MODULE_CHOSEN]
|
|
58
|
+
|
|
59
|
+
@app.get(path="/")
|
|
60
|
+
async def health_check() -> JSONResponse:
|
|
61
|
+
"""Health check endpoint."""
|
|
62
|
+
return JSONResponse(content={"status": "healthy", "service": title})
|
|
63
|
+
|
|
64
|
+
@app.post(path=webhook_path)
|
|
65
|
+
async def webhook_handler(
|
|
66
|
+
request: Request, background_tasks: BackgroundTasks
|
|
67
|
+
) -> JSONResponse:
|
|
68
|
+
"""
|
|
69
|
+
Webhook endpoint for receiving events from Unique platform.
|
|
70
|
+
|
|
71
|
+
This endpoint:
|
|
72
|
+
1. Verifies the webhook signature
|
|
73
|
+
2. Constructs an event from the payload
|
|
74
|
+
3. Calls the configured event handler
|
|
75
|
+
"""
|
|
76
|
+
# Get raw body and headers
|
|
77
|
+
body = await request.body()
|
|
78
|
+
headers = dict(request.headers)
|
|
79
|
+
|
|
80
|
+
from unique_toolkit.app.webhook import is_webhook_signature_valid
|
|
81
|
+
|
|
82
|
+
if not is_webhook_signature_valid(
|
|
83
|
+
headers=headers,
|
|
84
|
+
payload=body,
|
|
85
|
+
endpoint_secret=settings.app.endpoint_secret.get_secret_value(),
|
|
86
|
+
):
|
|
87
|
+
return JSONResponse(
|
|
88
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
89
|
+
content={"error": "Invalid webhook signature"},
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
event_data = json.loads(body.decode(encoding="utf-8"))
|
|
94
|
+
except json.JSONDecodeError as e:
|
|
95
|
+
logger.error(f"Error parsing event: {e}", exc_info=True)
|
|
96
|
+
return JSONResponse(
|
|
97
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
98
|
+
content={"error": f"Invalid event format: {str(e)}"},
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
if event_data["event"] not in subscribed_event_names:
|
|
102
|
+
return JSONResponse(
|
|
103
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
104
|
+
content={"error": "Not subscribed event"},
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
event = event_constructor(**event_data)
|
|
109
|
+
if event.filter_event(filter_options=settings.chat_event_filter_options):
|
|
110
|
+
return JSONResponse(
|
|
111
|
+
status_code=status.HTTP_200_OK,
|
|
112
|
+
content={"error": "Event filtered out"},
|
|
113
|
+
)
|
|
114
|
+
except ValidationError as e:
|
|
115
|
+
# pydantic errors https://docs.pydantic.dev/2.10/errors/errors/
|
|
116
|
+
logger.error(f"Validation error with model: {e.json()}", exc_info=True)
|
|
117
|
+
raise e
|
|
118
|
+
except ValueError as e:
|
|
119
|
+
logger.error(f"Error deserializing event: {e}", exc_info=True)
|
|
120
|
+
return JSONResponse(
|
|
121
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
122
|
+
content={"error": "Invalid event"},
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Run the task in background so that we don't block for long running tasks
|
|
126
|
+
background_tasks.add_task(event_handler, event)
|
|
127
|
+
return JSONResponse(
|
|
128
|
+
status_code=status.HTTP_200_OK, content={"message": "Event received"}
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return app
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Webhook signature verification for Unique platform.
|
|
3
|
+
|
|
4
|
+
Extracted from unique_sdk to provide standalone verification without event construction.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import hmac
|
|
9
|
+
import logging
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
_LOGGER = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_webhook_signature_valid(
|
|
16
|
+
headers: dict[str, str],
|
|
17
|
+
payload: bytes,
|
|
18
|
+
endpoint_secret: str,
|
|
19
|
+
tolerance: int = 300,
|
|
20
|
+
) -> bool:
|
|
21
|
+
"""
|
|
22
|
+
Verify webhook signature from Unique platform.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
headers: Request headers with X-Unique-Signature and X-Unique-Created-At
|
|
26
|
+
payload: Raw request body bytes
|
|
27
|
+
endpoint_secret: App endpoint secret from Unique platform
|
|
28
|
+
tolerance: Max seconds between timestamp and now (default: 300)
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
True if signature is valid, False otherwise
|
|
32
|
+
"""
|
|
33
|
+
# Extract headers
|
|
34
|
+
signature = headers.get("X-Unique-Signature") or headers.get("x-unique-signature")
|
|
35
|
+
timestamp_str = headers.get("X-Unique-Created-At") or headers.get(
|
|
36
|
+
"x-unique-created-at"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if not signature:
|
|
40
|
+
_LOGGER.error("Missing X-Unique-Signature header")
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
if not timestamp_str:
|
|
44
|
+
_LOGGER.error("Missing X-Unique-Created-At header")
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
# Convert timestamp to int
|
|
48
|
+
try:
|
|
49
|
+
timestamp = int(timestamp_str)
|
|
50
|
+
except ValueError:
|
|
51
|
+
_LOGGER.error(f"Invalid timestamp: {timestamp_str}")
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
# Decode payload if bytes
|
|
55
|
+
message = payload.decode("utf-8") if isinstance(payload, bytes) else payload
|
|
56
|
+
|
|
57
|
+
# Compute expected signature: HMAC-SHA256(message, secret)
|
|
58
|
+
expected_signature = hmac.new(
|
|
59
|
+
endpoint_secret.encode("utf-8"),
|
|
60
|
+
msg=message.encode("utf-8"),
|
|
61
|
+
digestmod=hashlib.sha256,
|
|
62
|
+
).hexdigest()
|
|
63
|
+
|
|
64
|
+
# Compare signatures (constant-time to prevent timing attacks)
|
|
65
|
+
if not hmac.compare_digest(expected_signature, signature):
|
|
66
|
+
_LOGGER.error("Signature mismatch. Ensure you're using the raw request body.")
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
# Check timestamp tolerance (prevent replay attacks)
|
|
70
|
+
if tolerance and timestamp < time.time() - tolerance:
|
|
71
|
+
_LOGGER.error(
|
|
72
|
+
f"Timestamp outside tolerance ({tolerance}s). Possible replay attack."
|
|
73
|
+
)
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
_LOGGER.debug("✅ Webhook signature verified successfully")
|
|
77
|
+
return True
|
unique_toolkit/chat/functions.py
CHANGED
|
@@ -263,7 +263,7 @@ def create_message(
|
|
|
263
263
|
references: list[ContentReference] | None = None,
|
|
264
264
|
debug_info: dict | None = None,
|
|
265
265
|
set_completed_at: bool | None = False,
|
|
266
|
-
):
|
|
266
|
+
) -> ChatMessage:
|
|
267
267
|
"""Creates a message in the chat session synchronously.
|
|
268
268
|
|
|
269
269
|
Args:
|
|
@@ -144,7 +144,7 @@ def search_contents(
|
|
|
144
144
|
chat_id: str,
|
|
145
145
|
where: dict,
|
|
146
146
|
include_failed_content: bool = False,
|
|
147
|
-
):
|
|
147
|
+
) -> list[Content]:
|
|
148
148
|
"""
|
|
149
149
|
Performs an asynchronous search for content files in the knowledge base by filter.
|
|
150
150
|
|
|
@@ -297,7 +297,7 @@ def upload_content_from_bytes(
|
|
|
297
297
|
skip_ingestion: bool = False,
|
|
298
298
|
ingestion_config: unique_sdk.Content.IngestionConfig | None = None,
|
|
299
299
|
metadata: dict[str, Any] | None = None,
|
|
300
|
-
):
|
|
300
|
+
) -> Content:
|
|
301
301
|
"""
|
|
302
302
|
Uploads content to the knowledge base.
|
|
303
303
|
|
|
@@ -347,7 +347,7 @@ def upload_content(
|
|
|
347
347
|
skip_excel_ingestion: bool = False,
|
|
348
348
|
ingestion_config: unique_sdk.Content.IngestionConfig | None = None,
|
|
349
349
|
metadata: dict[str, Any] | None = None,
|
|
350
|
-
):
|
|
350
|
+
) -> Content:
|
|
351
351
|
"""
|
|
352
352
|
Uploads content to the knowledge base.
|
|
353
353
|
|
|
@@ -399,7 +399,7 @@ def _trigger_upload_content(
|
|
|
399
399
|
skip_excel_ingestion: bool = False,
|
|
400
400
|
ingestion_config: unique_sdk.Content.IngestionConfig | None = None,
|
|
401
401
|
metadata: dict[str, Any] | None = None,
|
|
402
|
-
):
|
|
402
|
+
) -> Content:
|
|
403
403
|
"""
|
|
404
404
|
Uploads content to the knowledge base.
|
|
405
405
|
|
|
@@ -528,7 +528,7 @@ class ContentService:
|
|
|
528
528
|
skip_excel_ingestion: bool = False,
|
|
529
529
|
ingestion_config: unique_sdk.Content.IngestionConfig | None = None,
|
|
530
530
|
metadata: dict[str, Any] | None = None,
|
|
531
|
-
):
|
|
531
|
+
) -> Content:
|
|
532
532
|
"""
|
|
533
533
|
Uploads content to the knowledge base.
|
|
534
534
|
|