From a9c3d8146f64373fd84f67d349332a6f9f93a54d Mon Sep 17 00:00:00 2001 From: radu-mocanu Date: Mon, 22 Dec 2025 16:34:07 +0200 Subject: [PATCH] feat: add ixp interrupt models --- pyproject.toml | 2 +- src/uipath/platform/common/__init__.py | 4 + .../platform/common/interrupt_models.py | 67 +++++++++++----- .../platform/resume_triggers/_protocol.py | 78 +++++++++++++++++-- tests/cli/test_hitl.py | 56 +++++++++++++ uv.lock | 2 +- 6 files changed, 182 insertions(+), 27 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index aa033fa2d..b587d65e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.2.41" +version = "2.2.42" 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" diff --git a/src/uipath/platform/common/__init__.py b/src/uipath/platform/common/__init__.py index 1cf9ecc0b..ccfee7ef8 100644 --- a/src/uipath/platform/common/__init__.py +++ b/src/uipath/platform/common/__init__.py @@ -13,11 +13,13 @@ from .interrupt_models import ( CreateBatchTransform, CreateDeepRag, + CreateDocumentExtraction, CreateEscalation, CreateTask, InvokeProcess, WaitBatchTransform, WaitDeepRag, + WaitDocumentExtraction, WaitEscalation, WaitJob, WaitTask, @@ -44,4 +46,6 @@ "WaitDeepRag", "CreateBatchTransform", "WaitBatchTransform", + "CreateDocumentExtraction", + "WaitDocumentExtraction", ] diff --git a/src/uipath/platform/common/interrupt_models.py b/src/uipath/platform/common/interrupt_models.py index 59be423b0..f0043505d 100644 --- a/src/uipath/platform/common/interrupt_models.py +++ b/src/uipath/platform/common/interrupt_models.py @@ -1,8 +1,8 @@ """Models for interrupt operations in UiPath platform.""" -from typing import Annotated, Any, Dict, Optional +from typing import Annotated, Any -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator from ..action_center import Task from ..context_grounding import ( @@ -11,6 +11,7 @@ CitationMode, DeepRagCreationResponse, ) +from ..documents import FileContent, StartExtractionResponse from ..orchestrator import Job @@ -18,29 +19,29 @@ class InvokeProcess(BaseModel): """Model representing a process invocation.""" name: str - process_folder_path: Optional[str] = None - process_folder_key: Optional[str] = None - input_arguments: Optional[Dict[str, Any]] + process_folder_path: str | None = None + process_folder_key: str | None = None + input_arguments: dict[str, Any] | None class WaitJob(BaseModel): """Model representing a wait job operation.""" job: Job - process_folder_path: Optional[str] = None - process_folder_key: Optional[str] = None + process_folder_path: str | None = None + process_folder_key: str | None = None class CreateTask(BaseModel): """Model representing an action creation.""" title: str - data: Optional[Dict[str, Any]] = None - assignee: Optional[str] = "" - app_name: Optional[str] = None - app_folder_path: Optional[str] = None - app_folder_key: Optional[str] = None - app_key: Optional[str] = None + data: dict[str, Any] | None = None + assignee: str | None = "" + app_name: str | None = None + app_folder_path: str | None = None + app_folder_key: str | None = None + app_key: str | None = None class CreateEscalation(CreateTask): @@ -53,8 +54,8 @@ class WaitTask(BaseModel): """Model representing a wait action operation.""" action: Task - app_folder_path: Optional[str] = None - app_folder_key: Optional[str] = None + app_folder_path: str | None = None + app_folder_key: str | None = None class WaitEscalation(WaitTask): @@ -79,8 +80,8 @@ class WaitDeepRag(BaseModel): """Model representing a wait Deep RAG task.""" deep_rag: DeepRagCreationResponse - index_folder_path: Optional[str] = None - index_folder_key: Optional[str] = None + index_folder_path: str | None = None + index_folder_key: str | None = None class CreateBatchTransform(BaseModel): @@ -103,5 +104,33 @@ class WaitBatchTransform(BaseModel): """Model representing a wait Batch Transform task.""" batch_transform: BatchTransformCreationResponse - index_folder_path: Optional[str] = None - index_folder_key: Optional[str] = None + index_folder_path: str | None = None + index_folder_key: str | None = None + + +class CreateDocumentExtraction(BaseModel): + """Model representing a document extraction task creation.""" + + project_name: str + tag: str + file: FileContent | None = None + file_path: str | None = None + + model_config = ConfigDict( + arbitrary_types_allowed=True, + ) + + @model_validator(mode="after") + def validate_exactly_one_file_source(self) -> "CreateDocumentExtraction": + """Validate that exactly one of file or file_path is provided.""" + if (self.file is None) == (self.file_path is None): + raise ValueError( + "Exactly one of 'file' or 'file_path' must be provided, not both or neither" + ) + return self + + +class WaitDocumentExtraction(BaseModel): + """Model representing a wait document extraction task creation.""" + + extraction: StartExtractionResponse diff --git a/src/uipath/platform/resume_triggers/_protocol.py b/src/uipath/platform/resume_triggers/_protocol.py index 3205fe4e6..a0c289aac 100644 --- a/src/uipath/platform/resume_triggers/_protocol.py +++ b/src/uipath/platform/resume_triggers/_protocol.py @@ -24,17 +24,22 @@ from uipath.platform.common import ( CreateBatchTransform, CreateDeepRag, + CreateDocumentExtraction, CreateEscalation, CreateTask, InvokeProcess, WaitBatchTransform, WaitDeepRag, + WaitDocumentExtraction, WaitEscalation, WaitJob, WaitTask, ) from uipath.platform.context_grounding import DeepRagStatus -from uipath.platform.errors import BatchTransformNotCompleteException +from uipath.platform.errors import ( + BatchTransformNotCompleteException, + ExtractionNotCompleteException, +) from uipath.platform.orchestrator.job import JobState from uipath.platform.resume_triggers._enums import PropertyName, TriggerMarker @@ -244,6 +249,28 @@ async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None: return f"Batch transform completed. Modified file available at {os.path.abspath(destination_path)}" + case UiPathResumeTriggerType.IXP_EXTRACTION: + if trigger.item_key: + project_id = self._extract_field("project_id", trigger.payload) + tag = self._extract_field("tag", trigger.payload) + + assert project_id is not None + assert tag is not None + + try: + extraction_response = ( + await uipath.documents.retrieve_ixp_extraction_result_async( + project_id, tag, trigger.item_key + ) + ) + except ExtractionNotCompleteException as e: + raise UiPathPendingTriggerError( + ErrorCategory.SYSTEM, + f"{e.message}", + ) from e + + return extraction_response.model_dump() + case UiPathResumeTriggerType.API: if trigger.api_resume and trigger.api_resume.inbox_id: try: @@ -331,7 +358,10 @@ async def create_trigger(self, suspend_value: Any) -> UiPathResumeTrigger: await self._handle_batch_rag_job_trigger( suspend_value, resume_trigger, uipath ) - + case UiPathResumeTriggerType.IXP_EXTRACTION: + await self._handle_ixp_extraction_trigger( + suspend_value, resume_trigger, uipath + ) case _: raise UiPathFaultedTriggerError( ErrorCategory.SYSTEM, @@ -363,6 +393,8 @@ def _determine_trigger_type(self, value: Any) -> UiPathResumeTriggerType: return UiPathResumeTriggerType.DEEP_RAG if isinstance(value, (CreateBatchTransform, WaitBatchTransform)): return UiPathResumeTriggerType.BATCH_RAG + if isinstance(value, (CreateDocumentExtraction, WaitDocumentExtraction)): + return UiPathResumeTriggerType.IXP_EXTRACTION # default to API trigger return UiPathResumeTriggerType.API @@ -385,6 +417,8 @@ def _determine_trigger_name(self, value: Any) -> UiPathResumeTriggerName: return UiPathResumeTriggerName.DEEP_RAG if isinstance(value, (CreateBatchTransform, WaitBatchTransform)): return UiPathResumeTriggerName.BATCH_RAG + if isinstance(value, (CreateDocumentExtraction, WaitDocumentExtraction)): + return UiPathResumeTriggerName.EXTRACTION # default to API trigger return UiPathResumeTriggerName.API @@ -420,10 +454,10 @@ async def _handle_task_trigger( async def _handle_deep_rag_job_trigger( self, value: Any, resume_trigger: UiPathResumeTrigger, uipath: UiPath ) -> None: - """Handle job-type resume triggers. + """Handle Deep RAG resume triggers. Args: - value: The suspend value (InvokeProcess or WaitJob) + value: The suspend value (CreateDeepRag or WaitDeepRag) resume_trigger: The resume trigger to populate uipath: The UiPath client instance """ @@ -448,10 +482,10 @@ async def _handle_deep_rag_job_trigger( async def _handle_batch_rag_job_trigger( self, value: Any, resume_trigger: UiPathResumeTrigger, uipath: UiPath ) -> None: - """Handle job-type resume triggers. + """Handle batch transform resume triggers. Args: - value: The suspend value (InvokeProcess or WaitJob) + value: The suspend value (CreateBatchTransform or WaitBatchTransform) resume_trigger: The resume trigger to populate uipath: The UiPath client instance """ @@ -474,6 +508,38 @@ async def _handle_batch_rag_job_trigger( raise Exception("Failed to start batch transform") resume_trigger.item_key = batch_transform.id + async def _handle_ixp_extraction_trigger( + self, value: Any, resume_trigger: UiPathResumeTrigger, uipath: UiPath + ) -> None: + """Handle IXP Extraction resume triggers. + + Args: + value: The suspend value (CreateDocumentExtraction or WaitDocumentExtraction) + resume_trigger: The resume trigger to populate + uipath: The UiPath client instance + """ + resume_trigger.folder_path = resume_trigger.folder_key = None + + if isinstance(value, WaitDocumentExtraction): + resume_trigger.item_key = value.extraction.operation_id + elif isinstance(value, CreateDocumentExtraction): + document_extraction = await uipath.documents.start_ixp_extraction_async( + project_name=value.project_name, + tag=value.tag, + file=value.file, + file_path=value.file_path, + ) + if not document_extraction: + raise Exception("Failed to start document extraction") + resume_trigger.item_key = document_extraction.operation_id + + # add project_id and tag to the payload dict (needed when reading the trigger) + assert isinstance(resume_trigger.payload, dict) + resume_trigger.payload.setdefault( + "project_id", document_extraction.project_id + ) + resume_trigger.payload.setdefault("tag", document_extraction.tag) + async def _handle_job_trigger( self, value: Any, resume_trigger: UiPathResumeTrigger, uipath: UiPath ) -> None: diff --git a/tests/cli/test_hitl.py b/tests/cli/test_hitl.py index a290aaf10..928e8e1b2 100644 --- a/tests/cli/test_hitl.py +++ b/tests/cli/test_hitl.py @@ -17,6 +17,7 @@ from uipath.platform.common import ( CreateBatchTransform, CreateDeepRag, + CreateDocumentExtraction, CreateTask, InvokeProcess, WaitBatchTransform, @@ -776,3 +777,58 @@ async def test_create_resume_trigger_wait_batch_transform( assert resume_trigger is not None assert resume_trigger.trigger_type == UiPathResumeTriggerType.BATCH_RAG assert resume_trigger.item_key == batch_transform_id + + +class TestDocumentExtractionModels: + """Tests for document extraction models.""" + + def test_create_document_extraction_with_file(self) -> None: + """Test CreateDocumentExtraction with file provided.""" + file_content = b"test content" + extraction = CreateDocumentExtraction( + project_name="test_project", + tag="test_tag", + file=file_content, + ) + + assert extraction.project_name == "test_project" + assert extraction.tag == "test_tag" + assert extraction.file == file_content + assert extraction.file_path is None + + def test_create_document_extraction_with_file_path(self) -> None: + """Test CreateDocumentExtraction with file_path provided.""" + extraction = CreateDocumentExtraction( + project_name="test_project", + tag="test_tag", + file_path="/path/to/file.pdf", + ) + + assert extraction.project_name == "test_project" + assert extraction.tag == "test_tag" + assert extraction.file is None + assert extraction.file_path == "/path/to/file.pdf" + + def test_create_document_extraction_with_both_raises_error(self) -> None: + """Test CreateDocumentExtraction with both file and file_path raises ValueError.""" + file_content = b"test content" + + with pytest.raises(ValueError) as exc_info: + CreateDocumentExtraction( + project_name="test_project", + tag="test_tag", + file=file_content, + file_path="/path/to/file.pdf", + ) + + assert "not both or neither" in str(exc_info.value) + + def test_create_document_extraction_with_neither_raises_error(self) -> None: + """Test CreateDocumentExtraction with neither file nor file_path raises ValueError.""" + with pytest.raises(ValueError) as exc_info: + CreateDocumentExtraction( + project_name="test_project", + tag="test_tag", + ) + + assert "not both or neither" in str(exc_info.value) diff --git a/uv.lock b/uv.lock index f6b98afeb..a933ec240 100644 --- a/uv.lock +++ b/uv.lock @@ -2477,7 +2477,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.2.41" +version = "2.2.42" source = { editable = "." } dependencies = [ { name = "click" },