|
| 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