From e57e41531954bf45eac33bcc0755484d52139b81 Mon Sep 17 00:00:00 2001 From: Mohammed Motasim Date: Tue, 29 Sep 2026 16:26:32 -0700 Subject: [PATCH] feat(context-grounding): support Semantic ephemeral indexes and search by index id Add EphemeralIndexUsage.SEMANTIC so an ephemeral index can be built from attachments for plain semantic search, and unified_search_by_id(_async) to search it: ephemeral indexes have no name, and unified_search looks the index up by name first. CLI: create-ephemeral accepts --usage Semantic (PDF, TXT); search accepts --index-id as an alternative to --index-name. Co-Authored-By: Claude Opus 5.5 --- packages/uipath-platform/pyproject.toml | 2 +- .../_context_grounding_service.py | 108 +++++++++++++++++- .../context_grounding/context_grounding.py | 1 + .../test_context_grounding_service.py | 100 ++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 4 +- .../_cli/services/cli_context_grounding.py | 36 +++++- .../cli/contract/test_sdk_cli_alignment.py | 2 + .../test_context_grounding_commands.py | 71 ++++++++++++ packages/uipath/uv.lock | 8 +- 10 files changed, 320 insertions(+), 14 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 1b57a68a4..3df1c4def 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.33" +version = "0.2.34" 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/_context_grounding_service.py b/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py index b4e9073e7..def997718 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 @@ -689,7 +689,7 @@ def create_ephemeral_index( """Create a new ephemeral context grounding index. Args: - usage (EphemeralIndexUsage): The task type for the ephemeral index (DeepRAG or BatchRAG) + usage (EphemeralIndexUsage): The task type for the ephemeral index (DeepRAG, BatchRAG or Semantic). A Semantic index is searched with `unified_search_by_id`. attachments (list[str]): The list of attachments ids from which the ephemeral index will be created folder_key (Optional[str]): The folder key to scope the ephemeral index to. folder_path (Optional[str]): The folder path to scope the ephemeral index to (resolved to a key if folder_key is not provided). @@ -726,7 +726,7 @@ async def create_ephemeral_index_async( """Create a new ephemeral context grounding index. Args: - usage (EphemeralIndexUsage): The task type for the ephemeral index (DeepRAG or BatchRAG) + usage (EphemeralIndexUsage): The task type for the ephemeral index (DeepRAG, BatchRAG or Semantic). A Semantic index is searched with `unified_search_by_id`. attachments (list[str]): The list of attachments ids from which the ephemeral index will be created folder_key (Optional[str]): The folder key to scope the ephemeral index to. folder_path (Optional[str]): The folder path to scope the ephemeral index to (resolved to a key if folder_key is not provided). @@ -1929,6 +1929,110 @@ async def unified_search_async( return UnifiedQueryResult.model_validate(response.json()) + @traced(name="contextgrounding_unified_search_by_id", run_type="uipath") + def unified_search_by_id( + self, + index_id: str, + query: str, + search_mode: SearchMode = SearchMode.SEMANTIC, + 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 identified by its id. + + Use this for indexes that have no name, such as an ephemeral index created + with `EphemeralIndexUsage.SEMANTIC`. The index is not looked up first, so + poll `retrieve_by_id` until `lastIngestionStatus` is `Successful` before + searching. + + Args: + index_id (str): The id 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 SEMANTIC. + 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. + """ + 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()) + + @traced(name="contextgrounding_unified_search_by_id", run_type="uipath") + async def unified_search_by_id_async( + self, + index_id: str, + query: str, + search_mode: SearchMode = SearchMode.SEMANTIC, + 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 identified by its id. + + Use this for indexes that have no name, such as an ephemeral index created + with `EphemeralIndexUsage.SEMANTIC`. The index is not looked up first, so + poll `retrieve_by_id_async` until `lastIngestionStatus` is `Successful` + before searching. + + Args: + index_id (str): The id 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 SEMANTIC. + 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. + """ + 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, 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 c5c0915bb..8d3c92c6a 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 @@ -37,6 +37,7 @@ class EphemeralIndexUsage(str, Enum): DEEP_RAG = "DeepRAG" BATCH_RAG = "BatchRAG" + SEMANTIC = "Semantic" class DeepRagStatus(str, Enum): 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 c72d593fd..2148c1241 100644 --- a/packages/uipath-platform/tests/services/test_context_grounding_service.py +++ b/packages/uipath-platform/tests/services/test_context_grounding_service.py @@ -25,6 +25,7 @@ DeepRagCreationResponse, DeepRagResponse, DropboxSourceConfig, + EphemeralIndexUsage, GoogleDriveSourceConfig, Indexer, OneDriveSourceConfig, @@ -3964,6 +3965,105 @@ def test_unified_search( == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.unified_search/{version}" ) + def test_create_ephemeral_index_semantic( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/createephemeral", + status_code=202, + json={"id": "semantic-index-id", "lastIngestionStatus": "InProgress"}, + ) + + index = service.create_ephemeral_index( + usage=EphemeralIndexUsage.SEMANTIC, attachments=["attachment-id"] + ) + + assert index.id == "semantic-index-id" + request_data = json.loads(httpx_mock.get_requests()[0].content) + assert request_data == { + "usage": "Semantic", + "dataSource": {"attachments": ["attachment-id"]}, + } + + def test_unified_search_by_id( + self, + httpx_mock: HTTPXMock, + service_no_folder: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v1.2/search/semantic-index-id", + status_code=200, + json={ + "semanticResults": { + "values": [ + { + "id": "0", + "source": "chart.pdf", + "page_number": 1, + "content": "Page one", + "score": 0.5, + } + ] + } + }, + ) + + response = service_no_folder.unified_search_by_id( + index_id="semantic-index-id", + query="clinical record", + number_of_results=2000, + threshold=0.0, + ) + + assert response.semantic_results is not None + assert response.semantic_results.values[0].source == "chart.pdf" + sent_requests = httpx_mock.get_requests() + assert len(sent_requests) == 1 # no index lookup by name + request_body = json.loads(sent_requests[0].content) + assert request_body == { + "searchMode": "Semantic", + "query": "clinical record", + "semanticSearchOptions": {"numberOfResults": 2000, "threshold": 0.0}, + } + assert ( + sent_requests[0].headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.unified_search_by_id/{version}" + ) + + @pytest.mark.anyio + async def test_unified_search_by_id_async( + self, + httpx_mock: HTTPXMock, + service_no_folder: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v1.2/search/semantic-index-id", + status_code=200, + json={"semanticResults": {"values": []}}, + ) + + response = await service_no_folder.unified_search_by_id_async( + index_id="semantic-index-id", query="clinical record" + ) + + assert response.semantic_results is not None + assert response.semantic_results.values == [] + sent_requests = httpx_mock.get_requests() + assert len(sent_requests) == 1 + assert sent_requests[0].method == "POST" + @pytest.mark.anyio async def test_unified_search_async( self, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 54ab45e3d..fee1df2ad 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.33" +version = "0.2.34" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 5acc57d48..5f21e196b 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.14.27" +version = "2.14.28" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.30, <0.6.0", "uipath-runtime>=0.13.5, <0.14.0", - "uipath-platform>=0.2.33, <0.3.0", + "uipath-platform>=0.2.34, <0.3.0", "uipath-ipc>=2.5.1, <2.6.0", "click>=8.3.3, <9.0.0", "httpx>=0.28.1", diff --git a/packages/uipath/src/uipath/_cli/services/cli_context_grounding.py b/packages/uipath/src/uipath/_cli/services/cli_context_grounding.py index 8b19255e0..2aa881129 100644 --- a/packages/uipath/src/uipath/_cli/services/cli_context_grounding.py +++ b/packages/uipath/src/uipath/_cli/services/cli_context_grounding.py @@ -268,7 +268,7 @@ def source_schema(source_type: Optional[str]) -> None: @click.option( "--usage", required=True, - type=click.Choice(["DeepRAG", "BatchRAG"]), + type=click.Choice(["DeepRAG", "BatchRAG", "Semantic"]), help="Task type for the ephemeral index", ) @click.option( @@ -300,11 +300,13 @@ def create_ephemeral( Supported file types: DeepRAG: PDF, TXT BatchRAG: CSV + Semantic: PDF, TXT (search it with 'search --index-id ') \b Examples: uipath context-grounding create-ephemeral --usage DeepRAG --files doc1.pdf --files doc2.pdf uipath context-grounding create-ephemeral --usage BatchRAG --files data.csv + uipath context-grounding create-ephemeral --usage Semantic --files chart.pdf """ from pathlib import Path @@ -313,6 +315,7 @@ def create_ephemeral( allowed_extensions = { "DeepRAG": {".pdf", ".txt"}, "BatchRAG": {".csv"}, + "Semantic": {".pdf", ".txt"}, } allowed = allowed_extensions[usage] for file_path in files: @@ -444,7 +447,10 @@ def ingest_index( @context_grounding.command(name="search") -@click.option("--index-name", required=True, help="Name of the index to search") +@click.option("--index-name", help="Name of the index to search") +@click.option( + "--index-id", help="ID of the index to search (ephemeral Semantic indexes)" +) @click.option("--query", required=True, help="Search query in natural language") @click.option( "--limit", @@ -474,7 +480,8 @@ def ingest_index( @service_command def search_index( ctx: click.Context, - index_name: str, + index_name: Optional[str], + index_id: Optional[str], query: str, limit: int, threshold: float, @@ -485,16 +492,37 @@ def search_index( format: Optional[str], output: Optional[str], ) -> Any: - """Search a context grounding index (regular indexes only). + """Search a context grounding index. + + \b + Two ways to specify the index: + Regular index: --index-name + --folder-path + Ephemeral index: --index-id (created with --usage Semantic) \b Examples: uipath context-grounding search --index-name my-index --query "What is the revenue?" uipath context-grounding search --index-name my-index --query "results" --limit 5 + uipath context-grounding search --index-id abc-123 --query "results" --limit 50 """ from uipath.platform.context_grounding import SearchMode + if not index_name and not index_id: + raise click.UsageError("Either --index-name or --index-id must be provided.") + if index_name and index_id: + raise click.UsageError("Provide either --index-name or --index-id, not both.") + client = ServiceCommandBase.get_client(ctx) + if index_id: + return client.context_grounding.unified_search_by_id( + index_id=index_id, + query=query, + number_of_results=limit, + threshold=threshold, + search_mode=SearchMode(search_mode), + folder_path=folder_path, + folder_key=folder_key, + ) return client.context_grounding.unified_search( name=index_name, query=query, diff --git a/packages/uipath/tests/cli/contract/test_sdk_cli_alignment.py b/packages/uipath/tests/cli/contract/test_sdk_cli_alignment.py index a690727d4..18e12f5c2 100644 --- a/packages/uipath/tests/cli/contract/test_sdk_cli_alignment.py +++ b/packages/uipath/tests/cli/contract/test_sdk_cli_alignment.py @@ -338,6 +338,8 @@ def _get_service_group(name: str) -> click.Group: CLI_EXCLUSIONS: dict[str, set[str]] = { # retrieve: --index-id routes to retrieve_by_id, not retrieve — exclude from alignment "context-grounding_retrieve": {"index_id"}, + # search: --index-id routes to unified_search_by_id, not unified_search + "context-grounding_search": {"index_id"}, # create: CLI-only options that build the source object internally "context-grounding_create": {"source_file", "bucket_source", "file_type"}, # batch-transform start: --columns-file is CLI-only (reads JSON, builds output_columns) diff --git a/packages/uipath/tests/cli/integration/test_context_grounding_commands.py b/packages/uipath/tests/cli/integration/test_context_grounding_commands.py index 7296d7080..767003075 100644 --- a/packages/uipath/tests/cli/integration/test_context_grounding_commands.py +++ b/packages/uipath/tests/cli/integration/test_context_grounding_commands.py @@ -320,6 +320,36 @@ def test_create_ephemeral_multiple_files( assert result.exit_code == 0 assert mock_client.attachments.upload.call_count == 2 + def test_create_ephemeral_semantic( + self, runner, mock_client, mock_env_vars, tmp_path + ): + test_file = tmp_path / "chart.pdf" + test_file.write_text("test content") + + import uuid + + from uipath.platform.context_grounding import EphemeralIndexUsage + + mock_client.attachments.upload.return_value = uuid.uuid4() + mock_client.context_grounding.create_ephemeral_index.return_value = _make_index( + name="ephemeral" + ) + + result = runner.invoke( + cli, + [ + "context-grounding", + "create-ephemeral", + "--usage", + "Semantic", + "--files", + str(test_file), + ], + ) + assert result.exit_code == 0 + call_kwargs = mock_client.context_grounding.create_ephemeral_index.call_args[1] + assert call_kwargs["usage"] == EphemeralIndexUsage.SEMANTIC + def test_create_ephemeral_missing_usage_fails( self, runner, mock_env_vars, tmp_path ): @@ -551,6 +581,47 @@ def test_search_with_limit(self, runner, mock_client, mock_env_vars): call_kwargs = mock_client.context_grounding.unified_search.call_args[1] assert call_kwargs["number_of_results"] == 3 + def test_search_by_id(self, runner, mock_client, mock_env_vars): + mock_client.context_grounding.unified_search_by_id.return_value = [] + result = runner.invoke( + cli, + [ + "context-grounding", + "search", + "--index-id", + "abc-123", + "--query", + "clinical record", + "--limit", + "2000", + ], + ) + assert result.exit_code == 0 + mock_client.context_grounding.unified_search.assert_not_called() + call_kwargs = mock_client.context_grounding.unified_search_by_id.call_args[1] + assert call_kwargs["index_id"] == "abc-123" + assert call_kwargs["number_of_results"] == 2000 + + def test_search_no_index_fails(self, runner, mock_env_vars): + result = runner.invoke(cli, ["context-grounding", "search", "--query", "test"]) + assert result.exit_code != 0 + + def test_search_both_index_fails(self, runner, mock_env_vars): + result = runner.invoke( + cli, + [ + "context-grounding", + "search", + "--index-name", + "X", + "--index-id", + "abc-123", + "--query", + "test", + ], + ) + assert result.exit_code != 0 + def test_search_missing_query_fails(self, runner, mock_env_vars): result = runner.invoke( cli, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 00cd8a8f2..ff035df74 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -7,10 +7,10 @@ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for exclude-newer-span = "P2D" [options.exclude-newer-package] -uipath-core = false uipath-ipc = false -uipath-platform = false uipath-runtime = false +uipath-platform = false +uipath-core = false [[package]] name = "aiohappyeyeballs" @@ -2599,7 +2599,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.14.27" +version = "2.14.28" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2762,7 +2762,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.33" +version = "0.2.34" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" },