Skip to content

Commit 8d819e6

Browse files
committed
feat: add uipath context-grounding CLI commands
Adds a new command group for working with Context Grounding (ECS) indexes directly from the terminal — the same semantic search agents use at runtime. Commands -------- uipath context-grounding list [--folder-path PATH] uipath context-grounding retrieve --index NAME --folder-path PATH uipath context-grounding search QUERY --index NAME --folder-path PATH [--limit N] uipath context-grounding ingest --index NAME --folder-path PATH uipath context-grounding delete --index NAME --folder-path PATH [--confirm] [--dry-run] All commands accept --folder-path / --folder-key, --format [json|table|csv], and -o FILE, consistent with `uipath buckets`. Design ------ - Adds list() / list_async() to ContextGroundingService (GET /ecs_/v2/indexes without $filter; retrieve() uses the same endpoint with $filter=Name eq '...') - list and search table output projects to key columns only — full objects returned for JSON/CSV so nothing is lost for scripting - --index as a named option (not positional) on all commands - --limit (min 1, default 10) for search; validated client-side - ingest and delete guard against index.id=None to avoid a silent SDK no-op - ingest checks index.in_progress_ingestion() before the HTTP call (fast-fail) - Error handling mirrors cli_buckets.py: HTTPStatusError 404 and the SDK's bare Exception("ContextGroundingIndex not found") both route through handle_not_found_error(); _handle_retrieve_error() typed -> NoReturn - All exceptions from delete_index() / ingest_data() surface as ClickException Files changed ------------- src/uipath/_services/context_grounding_service.py (list, list_async) src/uipath/_cli/services/cli_context_grounding.py (new — 5 commands) src/uipath/_cli/services/__init__.py (register group) tests/cli/integration/test_context_grounding_commands.py (new — 39 tests) tests/cli/contract/test_sdk_cli_alignment.py (5 new cases) tests/sdk/services/test_context_grounding_service.py (list, list_async, list_empty) Verified against alpha.uipath.com / goldenagents / DefaultTenant.
1 parent 171e313 commit 8d819e6

6 files changed

Lines changed: 1533 additions & 11 deletions

File tree

src/uipath/_cli/services/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
"""
99

1010
from .cli_buckets import buckets
11+
from .cli_context_grounding import context_grounding
1112

12-
__all__ = ["buckets", "register_service_commands"]
13+
__all__ = ["buckets", "context_grounding", "register_service_commands"]
1314

1415

1516
def register_service_commands(cli_group):
@@ -30,7 +31,7 @@ def register_service_commands(cli_group):
3031
Industry Precedent:
3132
AWS CLI, Azure CLI, and gcloud all use explicit registration.
3233
"""
33-
services = [buckets]
34+
services = [buckets, context_grounding]
3435

3536
for service in services:
3637
cli_group.add_command(service)
Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
"""Context Grounding service commands for UiPath CLI.
2+
3+
Context Grounding provides semantic search over indexed document collections,
4+
enabling RAG (Retrieval-Augmented Generation) for automation processes.
5+
6+
Commands:
7+
retrieve - Get details of a specific index by name
8+
search - Perform semantic search against an index
9+
ingest - Trigger re-ingestion of an index
10+
delete - Delete an index by name
11+
12+
Note:
13+
create_index is intentionally not exposed here — its source configuration
14+
(bucket, Google Drive, OneDrive, Dropbox, Confluence) is too complex to
15+
express cleanly as CLI flags. Use the Python SDK directly for index creation.
16+
"""
17+
# ruff: noqa: D301 - Using regular """ strings (not r""") for Click \b formatting
18+
19+
from typing import Any, NoReturn, Optional
20+
21+
import click
22+
from httpx import HTTPStatusError
23+
24+
from .._utils._service_base import (
25+
ServiceCommandBase,
26+
common_service_options,
27+
handle_not_found_error,
28+
service_command,
29+
)
30+
31+
# The SDK raises a bare Exception with this message when an index name doesn't exist.
32+
_INDEX_NOT_FOUND_MSG = "ContextGroundingIndex not found"
33+
34+
35+
def _handle_retrieve_error(index_name: str, e: Exception) -> NoReturn:
36+
"""Convert a retrieve() exception to a clean ClickException.
37+
38+
Mirrors the pattern used in cli_buckets.py:
39+
- bare Exception with the SDK's not-found message → structured not-found error
40+
- HTTPStatusError 404 → structured not-found error
41+
- anything else → re-raise so service_command's handler deals with it
42+
"""
43+
if isinstance(e, HTTPStatusError):
44+
if e.response.status_code == 404:
45+
handle_not_found_error("Index", index_name, e)
46+
raise e
47+
if _INDEX_NOT_FOUND_MSG.lower() in str(e).lower():
48+
handle_not_found_error("Index", index_name)
49+
raise click.ClickException(str(e)) from e
50+
51+
52+
@click.group(name="context-grounding")
53+
def context_grounding() -> None:
54+
"""Manage UiPath Context Grounding indexes and perform semantic search.
55+
56+
Context Grounding indexes documents from storage buckets or cloud drives
57+
and makes them searchable via natural language queries (RAG).
58+
59+
\b
60+
Commands:
61+
list - List all indexes in a folder
62+
retrieve - Get details of a specific index
63+
search - Query an index with natural language
64+
ingest - Trigger re-ingestion of an index
65+
delete - Delete an index
66+
67+
\b
68+
Examples:
69+
uipath context-grounding list --folder-path "Shared"
70+
uipath context-grounding retrieve --index "my-index" --folder-path "Shared"
71+
uipath context-grounding search "how to process invoices" --index "my-index" --folder-path "Shared"
72+
uipath context-grounding search "payment terms" --index "my-index" --folder-path "Shared" --limit 5
73+
uipath context-grounding ingest --index "my-index" --folder-path "Shared"
74+
uipath context-grounding delete --index "my-index" --folder-path "Shared" --confirm
75+
76+
\b
77+
Folder context:
78+
Set UIPATH_FOLDER_PATH to avoid passing --folder-path on every command:
79+
export UIPATH_FOLDER_PATH="Shared"
80+
"""
81+
pass
82+
83+
84+
@context_grounding.command("list")
85+
@common_service_options
86+
@service_command
87+
def list_indexes(
88+
ctx: click.Context,
89+
folder_path: Optional[str],
90+
folder_key: Optional[str],
91+
format: Optional[str],
92+
output: Optional[str],
93+
) -> Any:
94+
"""List all context grounding indexes in a folder.
95+
96+
\b
97+
Examples:
98+
uipath context-grounding list --folder-path "Shared"
99+
uipath context-grounding list --folder-path "Shared" --format json
100+
"""
101+
client = ServiceCommandBase.get_client(ctx)
102+
return client.context_grounding.list(
103+
folder_path=folder_path,
104+
folder_key=folder_key,
105+
)
106+
107+
108+
@context_grounding.command("retrieve")
109+
@click.option(
110+
"--index", "index_name", required=True, help="Name of the index to retrieve."
111+
)
112+
@common_service_options
113+
@service_command
114+
def retrieve(
115+
ctx: click.Context,
116+
index_name: str,
117+
folder_path: Optional[str],
118+
folder_key: Optional[str],
119+
format: Optional[str],
120+
output: Optional[str],
121+
) -> Any:
122+
"""Retrieve details of a context grounding index by name.
123+
124+
\b
125+
Examples:
126+
uipath context-grounding retrieve --index "my-index" --folder-path "Shared"
127+
uipath context-grounding retrieve --index "my-index" --folder-path "Shared" --format json
128+
"""
129+
client = ServiceCommandBase.get_client(ctx)
130+
131+
try:
132+
return client.context_grounding.retrieve(
133+
name=index_name,
134+
folder_path=folder_path,
135+
folder_key=folder_key,
136+
)
137+
except Exception as e:
138+
_handle_retrieve_error(index_name, e)
139+
140+
141+
@context_grounding.command("search")
142+
@click.argument("query")
143+
@click.option(
144+
"--index", "index_name", required=True, help="Name of the index to search."
145+
)
146+
@click.option(
147+
"--limit",
148+
"-n",
149+
"number_of_results",
150+
type=click.IntRange(min=1),
151+
default=10,
152+
show_default=True,
153+
help="Maximum number of results to return.",
154+
)
155+
@common_service_options
156+
@service_command
157+
def search(
158+
ctx: click.Context,
159+
query: str,
160+
index_name: str,
161+
number_of_results: int,
162+
folder_path: Optional[str],
163+
folder_key: Optional[str],
164+
format: Optional[str],
165+
output: Optional[str],
166+
) -> Any:
167+
"""Search an index with a natural language query.
168+
169+
QUERY is the natural language search string.
170+
171+
\b
172+
Examples:
173+
uipath context-grounding search "how to process invoices" --index "my-index" --folder-path "Shared"
174+
uipath context-grounding search "payment terms" --index "my-index" --folder-path "Shared" --limit 5
175+
uipath context-grounding search "approval workflow" --index "my-index" --folder-path "Shared" --format json
176+
uipath context-grounding search "invoice policy" --index "my-index" --folder-path "Shared" -o results.json
177+
"""
178+
from uipath.models.exceptions import IngestionInProgressException
179+
180+
client = ServiceCommandBase.get_client(ctx)
181+
182+
try:
183+
results = client.context_grounding.search(
184+
name=index_name,
185+
query=query,
186+
number_of_results=number_of_results,
187+
folder_path=folder_path,
188+
folder_key=folder_key,
189+
)
190+
except IngestionInProgressException as e:
191+
raise click.ClickException(
192+
f"Index '{index_name}' is currently being ingested. "
193+
"Please wait for ingestion to complete and try again."
194+
) from e
195+
except Exception as e:
196+
_handle_retrieve_error(index_name, e)
197+
198+
if not results:
199+
click.echo("No results found.", err=True)
200+
return []
201+
202+
# Table format: show only human-readable columns, truncate content.
203+
# JSON/CSV: return full objects so nothing is lost for scripting.
204+
fmt = format or "table"
205+
if fmt == "table":
206+
rows = []
207+
for r in results:
208+
content = r.content
209+
if len(content) > 120:
210+
content = content[:120] + "…"
211+
rows.append(
212+
{
213+
"score": round(r.score, 3) if r.score is not None else None,
214+
"source": r.source,
215+
"page_number": r.page_number,
216+
"content": content,
217+
}
218+
)
219+
return rows
220+
221+
return results
222+
223+
224+
@context_grounding.command("ingest")
225+
@click.option(
226+
"--index", "index_name", required=True, help="Name of the index to re-ingest."
227+
)
228+
@common_service_options
229+
@service_command
230+
def ingest(
231+
ctx: click.Context,
232+
index_name: str,
233+
folder_path: Optional[str],
234+
folder_key: Optional[str],
235+
format: Optional[str],
236+
output: Optional[str],
237+
) -> None:
238+
"""Trigger re-ingestion of a context grounding index.
239+
240+
\b
241+
Examples:
242+
uipath context-grounding ingest --index "my-index" --folder-path "Shared"
243+
"""
244+
from uipath.models.exceptions import IngestionInProgressException
245+
246+
client = ServiceCommandBase.get_client(ctx)
247+
248+
try:
249+
index = client.context_grounding.retrieve(
250+
name=index_name,
251+
folder_path=folder_path,
252+
folder_key=folder_key,
253+
)
254+
if not index.id:
255+
raise click.ClickException(
256+
f"Index '{index_name}' has no ID and cannot be ingested."
257+
)
258+
if index.in_progress_ingestion():
259+
raise click.ClickException(
260+
f"Index '{index_name}' is already being ingested."
261+
)
262+
client.context_grounding.ingest_data(
263+
index=index,
264+
folder_path=folder_path,
265+
folder_key=folder_key,
266+
)
267+
except click.ClickException:
268+
raise
269+
except IngestionInProgressException as e:
270+
# Catches the 409 race condition from ingest_data() itself.
271+
raise click.ClickException(
272+
f"Index '{index_name}' is already being ingested."
273+
) from e
274+
except Exception as e:
275+
_handle_retrieve_error(index_name, e)
276+
277+
click.echo(f"Triggered ingestion for index '{index_name}'.", err=True)
278+
279+
280+
@context_grounding.command("delete")
281+
@click.option(
282+
"--index", "index_name", required=True, help="Name of the index to delete."
283+
)
284+
@click.option("--confirm", is_flag=True, help="Skip confirmation prompt.")
285+
@click.option(
286+
"--dry-run", is_flag=True, help="Show what would be deleted without deleting."
287+
)
288+
@common_service_options
289+
@service_command
290+
def delete(
291+
ctx: click.Context,
292+
index_name: str,
293+
confirm: bool,
294+
dry_run: bool,
295+
folder_path: Optional[str],
296+
folder_key: Optional[str],
297+
format: Optional[str],
298+
output: Optional[str],
299+
) -> None:
300+
"""Delete a context grounding index by name.
301+
302+
\b
303+
Examples:
304+
uipath context-grounding delete --index "my-index" --folder-path "Shared" --confirm
305+
uipath context-grounding delete --index "my-index" --folder-path "Shared" --dry-run
306+
"""
307+
client = ServiceCommandBase.get_client(ctx)
308+
309+
# Resolve the index object first — surfaces not-found before prompting the user.
310+
try:
311+
index = client.context_grounding.retrieve(
312+
name=index_name,
313+
folder_path=folder_path,
314+
folder_key=folder_key,
315+
)
316+
except Exception as e:
317+
_handle_retrieve_error(index_name, e)
318+
319+
if not index.id:
320+
raise click.ClickException(
321+
f"Index '{index_name}' has no ID and cannot be deleted."
322+
)
323+
324+
# dry-run and confirmation after index is confirmed to exist and have an ID.
325+
if dry_run:
326+
click.echo(f"Would delete index '{index_name}'.", err=True)
327+
return
328+
329+
if not confirm:
330+
if not click.confirm(f"Delete index '{index_name}'?"):
331+
click.echo("Deletion cancelled.", err=True)
332+
return
333+
334+
try:
335+
client.context_grounding.delete_index(
336+
index=index,
337+
folder_path=folder_path,
338+
folder_key=folder_key,
339+
)
340+
except Exception as e:
341+
raise click.ClickException(f"Failed to delete index '{index_name}': {e}") from e
342+
343+
click.echo(f"Deleted index '{index_name}'.", err=True)

0 commit comments

Comments
 (0)