diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 27ddf50ce..ec92f1023 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.0.19" +version = "0.0.20" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/context_grounding/__init__.py b/packages/uipath-platform/src/uipath/platform/context_grounding/__init__.py index c43d64c68..9bcd7fb18 100644 --- a/packages/uipath-platform/src/uipath/platform/context_grounding/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/context_grounding/__init__.py @@ -15,6 +15,11 @@ DeepRagStatus, EphemeralIndexUsage, IndexStatus, + SearchMode, + SemanticSearchOptions, + SemanticSearchResult, + UnifiedQueryResult, + UnifiedSearchScope, ) from .context_grounding_index import ContextGroundingIndex from .context_grounding_payloads import ( @@ -64,6 +69,11 @@ "Indexer", "OneDriveDataSource", "OneDriveSourceConfig", + "SearchMode", + "SemanticSearchOptions", + "SemanticSearchResult", "SourceConfig", + "UnifiedQueryResult", + "UnifiedSearchScope", "Citation", ] diff --git a/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py b/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py index c8d0bea5f..8d93993b7 100644 --- a/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py +++ b/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py @@ -3,6 +3,7 @@ import httpx from pydantic import Field, TypeAdapter +from typing_extensions import deprecated from uipath.core.tracing import traced from ..common._base_service import BaseService @@ -33,6 +34,9 @@ DeepRagCreationResponse, DeepRagResponse, EphemeralIndexUsage, + SearchMode, + UnifiedQueryResult, + UnifiedSearchScope, ) from .context_grounding_index import ContextGroundingIndex from .context_grounding_payloads import ( @@ -1274,16 +1278,20 @@ async def start_deep_rag_ephemeral_async( @resource_override(resource_type="index") @traced(name="contextgrounding_search", run_type="uipath") + @deprecated("Use unified_search instead.") def search( self, name: str, query: str, number_of_results: int = 10, + threshold: Optional[float] = None, folder_key: Optional[str] = None, folder_path: Optional[str] = None, ) -> List[ContextGroundingQueryResponse]: """Search for contextual information within a specific index. + This method is deprecated. Use unified_search instead. + This method performs a semantic search against the specified context index, helping to find relevant information that can be used in automation processes. The search is powered by AI and understands natural language queries. @@ -1293,6 +1301,7 @@ def search( query (str): The search query in natural language. number_of_results (int, optional): Maximum number of results to return. Defaults to 10. + threshold (float): Minimum similarity threshold. Defaults to 0.0. Returns: List[ContextGroundingQueryResponse]: A list of search results, each containing @@ -1308,6 +1317,7 @@ def search( name, query, number_of_results, + threshold=threshold if threshold is not None else 0.0, folder_key=folder_key, folder_path=folder_path, ) @@ -1325,16 +1335,20 @@ def search( @resource_override(resource_type="index") @traced(name="contextgrounding_search", run_type="uipath") + @deprecated("Use unified_search_async instead.") async def search_async( self, name: str, query: str, number_of_results: int = 10, + threshold: Optional[float] = None, folder_key: Optional[str] = None, folder_path: Optional[str] = None, ) -> List[ContextGroundingQueryResponse]: """Search asynchronously for contextual information within a specific index. + This method is deprecated. Use unified_search_async instead. + This method performs a semantic search against the specified context index, helping to find relevant information that can be used in automation processes. The search is powered by AI and understands natural language queries. @@ -1344,6 +1358,7 @@ async def search_async( query (str): The search query in natural language. number_of_results (int, optional): Maximum number of results to return. Defaults to 10. + threshold (float): Minimum similarity threshold. Defaults to 0.0. Returns: List[ContextGroundingQueryResponse]: A list of search results, each containing @@ -1363,6 +1378,7 @@ async def search_async( name, query, number_of_results, + threshold=threshold if threshold is not None else 0.0, folder_key=folder_key, folder_path=folder_path, ) @@ -1378,6 +1394,120 @@ async def search_async( response.json() ) + @resource_override(resource_type="index") + @traced(name="contextgrounding_unified_search", run_type="uipath") + def unified_search( + self, + name: str, + query: str, + search_mode: SearchMode = SearchMode.AUTO, + number_of_results: int = 10, + threshold: float = 0.0, + scope: Optional[UnifiedSearchScope] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> UnifiedQueryResult: + """Perform a unified search on a context grounding index. + + This method performs a unified search (v1.2) against the specified context index, + supporting both semantic and tabular search modes. + + Args: + name (str): The name of the context index to search in. + query (str): The search query in natural language. + search_mode (SearchMode): The search mode to use. Defaults to AUTO. + number_of_results (int): Maximum number of results to return. Defaults to 10. + threshold (float): Minimum similarity threshold. Defaults to 0.0. + scope (Optional[UnifiedSearchScope]): Optional search scope (folder, extension). + folder_key (Optional[str]): The key of the folder where the index resides. + folder_path (Optional[str]): The path of the folder where the index resides. + + Returns: + UnifiedQueryResult: The unified search result containing semantic and/or tabular results. + """ + index = self.retrieve(name, folder_key=folder_key, folder_path=folder_path) + + folder_key = folder_key or index.folder_key + + spec = self._unified_search_spec( + index_id=index.id, + query=query, + search_mode=search_mode, + number_of_results=number_of_results, + threshold=threshold, + scope=scope, + folder_key=folder_key, + folder_path=folder_path, + ) + + response = self.request( + spec.method, + spec.endpoint, + json=spec.json, + headers=spec.headers, + ) + + return UnifiedQueryResult.model_validate(response.json()) + + @resource_override(resource_type="index") + @traced(name="contextgrounding_unified_search", run_type="uipath") + async def unified_search_async( + self, + name: str, + query: str, + search_mode: SearchMode = SearchMode.AUTO, + number_of_results: int = 10, + threshold: float = 0.0, + scope: Optional[UnifiedSearchScope] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> UnifiedQueryResult: + """Asynchronously perform a unified search on a context grounding index. + + This method performs a unified search (v1.2) against the specified context index, + supporting both semantic and tabular search modes. + + Args: + name (str): The name of the context index to search in. + query (str): The search query in natural language. + search_mode (SearchMode): The search mode to use. Defaults to AUTO. + number_of_results (int): Maximum number of results to return. Defaults to 10. + threshold (float): Minimum similarity threshold. Defaults to 0.0. + scope (Optional[UnifiedSearchScope]): Optional search scope (folder, extension). + folder_key (Optional[str]): The key of the folder where the index resides. + folder_path (Optional[str]): The path of the folder where the index resides. + + Returns: + UnifiedQueryResult: The unified search result containing semantic and/or tabular results. + """ + index = await self.retrieve_async( + name, folder_key=folder_key, folder_path=folder_path + ) + if index and index.in_progress_ingestion(): + raise IngestionInProgressException(index_name=name) + + folder_key = folder_key or index.folder_key + + spec = self._unified_search_spec( + index_id=index.id, + query=query, + search_mode=search_mode, + number_of_results=number_of_results, + threshold=threshold, + scope=scope, + folder_key=folder_key, + folder_path=folder_path, + ) + + response = await self.request_async( + spec.method, + spec.endpoint, + json=spec.json, + headers=spec.headers, + ) + + return UnifiedQueryResult.model_validate(response.json()) + @traced(name="contextgrounding_ingest_data", run_type="uipath") def ingest_data( self, @@ -1757,6 +1887,7 @@ def _search_spec( name: str, query: str, number_of_results: int = 10, + threshold: float = 0.0, folder_key: Optional[str] = None, folder_path: Optional[str] = None, ) -> RequestSpec: @@ -1766,7 +1897,11 @@ def _search_spec( method="POST", endpoint=Endpoint("/ecs_/v1/search"), json={ - "query": {"query": query, "numberOfResults": number_of_results}, + "query": { + "query": query, + "numberOfResults": number_of_results, + "threshold": threshold, + }, "schema": {"name": name}, }, headers={ @@ -1774,6 +1909,42 @@ def _search_spec( }, ) + def _unified_search_spec( + self, + index_id: str, + query: str, + search_mode: SearchMode = SearchMode.AUTO, + number_of_results: int = 10, + threshold: float = 0.0, + scope: Optional[UnifiedSearchScope] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> RequestSpec: + folder_key = self._resolve_folder_key(folder_key, folder_path) + + json_body: Dict[str, Any] = { + "searchMode": search_mode.value + if isinstance(search_mode, SearchMode) + else search_mode, + "query": query, + "semanticSearchOptions": { + "numberOfResults": number_of_results, + "threshold": threshold, + }, + } + + if scope is not None: + json_body["scope"] = scope.model_dump(by_alias=True, exclude_none=True) + + return RequestSpec( + method="POST", + endpoint=Endpoint(f"/ecs_/v1.2/search/{index_id}"), + json=json_body, + headers={ + **header_folder(folder_key, None), + }, + ) + def _deep_rag_creation_spec( self, index_id: str, diff --git a/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding.py b/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding.py index 275a05002..9f464e1f6 100644 --- a/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding.py +++ b/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding.py @@ -209,3 +209,73 @@ class ContextGroundingQueryResponse(BaseModel): caption: Optional[str] = Field(default=None, alias="caption") score: Optional[float] = Field(default=None, alias="score") reference: Optional[str] = Field(default=None, alias="reference") + + +class SearchMode(str, Enum): + """Enum representing possible unified search modes.""" + + AUTO = "Auto" + SEMANTIC = "Semantic" + + +class UnifiedSearchScope(BaseModel): + """Model representing the scope for a unified search request.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) + + folder: Optional[str] = Field(default=None) + extension: Optional[str] = Field(default=None) + + +class SemanticSearchOptions(BaseModel): + """Model representing semantic search options for a unified search request.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) + + number_of_results: int = Field(default=3, alias="numberOfResults") + threshold: float = Field(default=0.0) + + +class SemanticSearchResult(BaseModel): + """Model representing a semantic search result from a unified search.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) + + values: list[ContextGroundingQueryResponse] = Field( + default_factory=list, alias="values" + ) + + +class UnifiedQueryResult(BaseModel): + """Model representing the result of a unified search query.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) + + semantic_results: Optional[SemanticSearchResult] = Field( + default=None, alias="semanticResults" + ) + explanation: Optional[str] = Field(default=None) diff --git a/packages/uipath-platform/tests/services/test_context_grounding_service.py b/packages/uipath-platform/tests/services/test_context_grounding_service.py index 835f5ee9a..1a878c40d 100644 --- a/packages/uipath-platform/tests/services/test_context_grounding_service.py +++ b/packages/uipath-platform/tests/services/test_context_grounding_service.py @@ -24,6 +24,9 @@ GoogleDriveSourceConfig, Indexer, OneDriveSourceConfig, + SearchMode, + UnifiedQueryResult, + UnifiedSearchScope, ) from uipath.platform.context_grounding._context_grounding_service import ( ContextGroundingService, @@ -2771,3 +2774,267 @@ async def test_download_batch_transform_result_async_encrypted( assert "/DownloadBlob" in str(download_request.url) assert "Authorization" in download_request.headers assert download_request.headers["Authorization"].startswith("Bearer ") + + def test_unified_search( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes?$filter=Name eq 'test-index'&$expand=dataSource", + status_code=200, + json={ + "value": [ + { + "id": "test-index-id", + "name": "test-index", + "lastIngestionStatus": "Completed", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v1.2/search/test-index-id", + status_code=200, + json={ + "semanticResults": { + "values": [ + { + "source": "test-source", + "page_number": "1", + "content": "Test content", + "metadata": { + "operation_id": "test-op", + "strategy": "test-strategy", + }, + "score": 0.95, + } + ] + }, + "explanation": "test explanation", + }, + ) + + response = service.unified_search( + name="test-index", + query="test query", + search_mode=SearchMode.SEMANTIC, + number_of_results=5, + ) + + assert isinstance(response, UnifiedQueryResult) + assert response.explanation == "test explanation" + assert response.semantic_results is not None + assert len(response.semantic_results.values) == 1 + assert response.semantic_results.values[0].source == "test-source" + assert response.semantic_results.values[0].content == "Test content" + assert response.semantic_results.values[0].score == 0.95 + + sent_requests = httpx_mock.get_requests() + if sent_requests is None: + raise Exception("No request was sent") + + search_request = sent_requests[3] + assert search_request.method == "POST" + assert ( + str(search_request.url) + == f"{base_url}{org}{tenant}/ecs_/v1.2/search/test-index-id" + ) + request_body = json.loads(search_request.content) + assert request_body["searchMode"] == "Semantic" + assert request_body["query"] == "test query" + assert request_body["semanticSearchOptions"]["numberOfResults"] == 5 + + assert HEADER_USER_AGENT in search_request.headers + assert ( + search_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.unified_search/{version}" + ) + + @pytest.mark.anyio + async def test_unified_search_async( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes?$filter=Name eq 'test-index'&$expand=dataSource", + status_code=200, + json={ + "value": [ + { + "id": "test-index-id", + "name": "test-index", + "lastIngestionStatus": "Completed", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v1.2/search/test-index-id", + status_code=200, + json={ + "semanticResults": { + "values": [ + { + "source": "test-source", + "page_number": "1", + "content": "Test content", + "metadata": { + "operation_id": "test-op", + "strategy": "test-strategy", + }, + "score": 0.95, + } + ] + }, + "explanation": "test explanation", + }, + ) + + response = await service.unified_search_async( + name="test-index", + query="test query", + search_mode=SearchMode.AUTO, + ) + + assert isinstance(response, UnifiedQueryResult) + assert response.explanation == "test explanation" + assert response.semantic_results is not None + assert len(response.semantic_results.values) == 1 + + def test_unified_search_with_scope( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes?$filter=Name eq 'test-index'&$expand=dataSource", + status_code=200, + json={ + "value": [ + { + "id": "test-index-id", + "name": "test-index", + "lastIngestionStatus": "Completed", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v1.2/search/test-index-id", + status_code=200, + json={ + "semanticResults": {"values": []}, + "explanation": None, + }, + ) + + scope = UnifiedSearchScope(folder="docs", extension=".pdf") + + response = service.unified_search( + name="test-index", + query="test query", + scope=scope, + ) + + assert isinstance(response, UnifiedQueryResult) + + sent_requests = httpx_mock.get_requests() + search_request = sent_requests[3] + request_body = json.loads(search_request.content) + assert "filter" not in request_body + assert request_body["scope"]["folder"] == "docs" + assert request_body["scope"]["extension"] == ".pdf" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index ec719e7bf..842a01e77 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.0.19" +version = "0.0.20" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index f0f572038..5dde6c99e 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2679,7 +2679,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.0.19" +version = "0.0.20" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" },