From 0d6c0f2d37467556b021afbd30bf6d4e5785f917 Mon Sep 17 00:00:00 2001 From: paulruelle Date: Thu, 19 Mar 2026 17:51:16 +0100 Subject: [PATCH] feat(LAB-4295): add granular SDK methods for workflow step operations Add 5 new client methods for individual workflow step management: - add_review_step: add a review step with assignees and properties - update_labeling_step_properties: update consensus/honeypot on labeling steps - update_review_step_properties: update coverage/send-back/honeypot on review steps - delete_step: remove a step by name - rename_step: rename a step Each method follows the existing SDK pattern (client -> use_cases -> gateway) with dedicated GraphQL mutations, input types, and snake_case to camelCase mappers. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../project_workflow/mappers.py | 83 +++++- .../project_workflow/operations.py | 59 ++++ .../project_workflow/operations_mixin.py | 59 +++- .../project_workflow/types.py | 62 ++++- .../presentation/client/project_workflow.py | 235 +++++++++++++++- .../use_cases/project_workflow/__init__.py | 262 +++++++++++++++++- 6 files changed, 738 insertions(+), 22 deletions(-) diff --git a/src/kili/adapters/kili_api_gateway/project_workflow/mappers.py b/src/kili/adapters/kili_api_gateway/project_workflow/mappers.py index 6effef0bc..692583d9f 100644 --- a/src/kili/adapters/kili_api_gateway/project_workflow/mappers.py +++ b/src/kili/adapters/kili_api_gateway/project_workflow/mappers.py @@ -1,10 +1,15 @@ """GraphQL payload data mappers for project operations.""" -from typing import Union - from kili.domain.project import WorkflowStepCreate, WorkflowStepUpdate -from .types import ProjectWorkflowDataKiliAPIGatewayInput +from .types import ( + AddReviewStepInput, + DeleteStepInput, + ProjectWorkflowDataKiliAPIGatewayInput, + RenameStepInput, + UpdateLabelingStepPropertiesInput, + UpdateReviewStepPropertiesInput, +) def project_input_mapper(data: ProjectWorkflowDataKiliAPIGatewayInput) -> dict: @@ -18,12 +23,12 @@ def project_input_mapper(data: ProjectWorkflowDataKiliAPIGatewayInput) -> dict: "updates": [update_step_mapper(step) for step in data.update_steps] if data.update_steps else [], - "deletes": data.delete_steps if data.delete_steps else [], + "deletes": data.delete_steps or [], }, } -def update_step_mapper(data: Union[WorkflowStepCreate, WorkflowStepUpdate]) -> dict: +def update_step_mapper(data: WorkflowStepCreate | WorkflowStepUpdate) -> dict: """Build the GraphQL create StepData variable to be sent in an operation.""" step = { "id": data["id"] if "id" in data else None, @@ -35,5 +40,73 @@ def update_step_mapper(data: Union[WorkflowStepCreate, WorkflowStepUpdate]) -> d "stepCoverage": data["step_coverage"] if "step_coverage" in data else None, "type": data["type"] if "type" in data else None, "assignees": data["assignees"] if "assignees" in data else None, + "sendBackStepId": data["send_back_step_id"] if "send_back_step_id" in data else None, } return {k: v for k, v in step.items() if v is not None} + + +def add_review_step_input_mapper(data: AddReviewStepInput) -> dict: + """Build the GraphQL AddReviewStepInput variable.""" + result: dict = { + "projectId": data.project_id, + "name": data.name, + "assignees": data.assignees, + } + if data.consensus_coverage is not None: + result["consensusCoverage"] = data.consensus_coverage + if data.number_of_expected_labels_for_consensus is not None: + result["numberOfExpectedLabelsForConsensus"] = data.number_of_expected_labels_for_consensus + if data.step_coverage is not None: + result["stepCoverage"] = data.step_coverage + if data.use_honeypot is not None: + result["useHoneypot"] = data.use_honeypot + if data.send_back_to_step is not None: + result["sendBackToStep"] = data.send_back_to_step + return result + + +def update_labeling_step_properties_input_mapper(data: UpdateLabelingStepPropertiesInput) -> dict: + """Build the GraphQL UpdateLabelingStepPropertiesInput variable.""" + result: dict = { + "projectId": data.project_id, + "stepName": data.step_name, + } + if data.consensus_coverage is not None: + result["consensusCoverage"] = data.consensus_coverage + if data.number_of_expected_labels_for_consensus is not None: + result["numberOfExpectedLabelsForConsensus"] = data.number_of_expected_labels_for_consensus + if data.use_honeypot is not None: + result["useHoneypot"] = data.use_honeypot + return result + + +def update_review_step_properties_input_mapper(data: UpdateReviewStepPropertiesInput) -> dict: + """Build the GraphQL UpdateReviewStepPropertiesInput variable.""" + result: dict = { + "projectId": data.project_id, + "stepName": data.step_name, + } + if data.step_coverage is not None: + result["stepCoverage"] = data.step_coverage + if data.send_back_to_step is not None: + result["sendBackToStep"] = data.send_back_to_step + if data.use_honeypot is not None: + result["useHoneypot"] = data.use_honeypot + return result + + +def delete_step_input_mapper(data: DeleteStepInput) -> dict: + """Build the GraphQL DeleteStepInput variable.""" + return { + "projectId": data.project_id, + "stepName": data.step_name, + } + + +def rename_step_input_mapper(data: RenameStepInput) -> dict: + """Build the GraphQL RenameStepInput variable.""" + return { + "projectId": data.project_id, + "stepName": data.step_name, + "newName": data.new_name, + } diff --git a/src/kili/adapters/kili_api_gateway/project_workflow/operations.py b/src/kili/adapters/kili_api_gateway/project_workflow/operations.py index 8eb5bee91..07d5bbb7e 100644 --- a/src/kili/adapters/kili_api_gateway/project_workflow/operations.py +++ b/src/kili/adapters/kili_api_gateway/project_workflow/operations.py @@ -21,3 +21,62 @@ def get_steps_query(fragment: str) -> str: }} }} """ + + +def get_add_review_step_mutation() -> str: + """Return the GraphQL addReviewStep mutation.""" + return """ + mutation addReviewStep($input: AddReviewStepInput!) { + data: addReviewStep(input: $input) { + id + name + } + } + """ + + +def get_update_labeling_step_properties_mutation() -> str: + """Return the GraphQL updateLabelingStepProperties mutation.""" + return """ + mutation updateLabelingStepProperties($input: UpdateLabelingStepPropertiesInput!) { + data: updateLabelingStepProperties(input: $input) { + id + name + } + } + """ + + +def get_update_review_step_properties_mutation() -> str: + """Return the GraphQL updateReviewStepProperties mutation.""" + return """ + mutation updateReviewStepProperties($input: UpdateReviewStepPropertiesInput!) { + data: updateReviewStepProperties(input: $input) { + id + name + } + } + """ + + +def get_delete_step_mutation() -> str: + """Return the GraphQL deleteStep mutation.""" + return """ + mutation deleteStep($input: DeleteStepInput!) { + data: deleteStep(input: $input) { + id + } + } + """ + + +def get_rename_step_mutation() -> str: + """Return the GraphQL renameStep mutation.""" + return """ + mutation renameStep($input: RenameStepInput!) { + data: renameStep(input: $input) { + id + name + } + } + """ diff --git a/src/kili/adapters/kili_api_gateway/project_workflow/operations_mixin.py b/src/kili/adapters/kili_api_gateway/project_workflow/operations_mixin.py index 8aad24d74..64ffbbf42 100644 --- a/src/kili/adapters/kili_api_gateway/project_workflow/operations_mixin.py +++ b/src/kili/adapters/kili_api_gateway/project_workflow/operations_mixin.py @@ -1,4 +1,5 @@ """Mixin extending Kili API Gateway class with Projects related operations.""" + import warnings from kili.adapters.kili_api_gateway.base import BaseOperationMixin @@ -11,12 +12,31 @@ from kili.domain.types import ListOrTuple from kili.exceptions import NotFound -from .mappers import project_input_mapper +from .mappers import ( + add_review_step_input_mapper, + delete_step_input_mapper, + project_input_mapper, + rename_step_input_mapper, + update_labeling_step_properties_input_mapper, + update_review_step_properties_input_mapper, +) from .operations import ( + get_add_review_step_mutation, + get_delete_step_mutation, + get_rename_step_mutation, get_steps_query, + get_update_labeling_step_properties_mutation, get_update_project_workflow_mutation, + get_update_review_step_properties_mutation, +) +from .types import ( + AddReviewStepInput, + DeleteStepInput, + ProjectWorkflowDataKiliAPIGatewayInput, + RenameStepInput, + UpdateLabelingStepPropertiesInput, + UpdateReviewStepPropertiesInput, ) -from .types import ProjectWorkflowDataKiliAPIGatewayInput class ProjectWorkflowOperationMixin(BaseOperationMixin): @@ -160,3 +180,38 @@ def remove_reviewers_from_step( ) return removed_emails + + def add_review_step(self, data: AddReviewStepInput) -> dict: + """Add a review step to a project workflow.""" + variables = {"input": add_review_step_input_mapper(data)} + mutation = get_add_review_step_mutation() + result = self.graphql_client.execute(mutation, variables) + return result["data"] + + def update_labeling_step_properties(self, data: UpdateLabelingStepPropertiesInput) -> dict: + """Update properties of a labeling step.""" + variables = {"input": update_labeling_step_properties_input_mapper(data)} + mutation = get_update_labeling_step_properties_mutation() + result = self.graphql_client.execute(mutation, variables) + return result["data"] + + def update_review_step_properties(self, data: UpdateReviewStepPropertiesInput) -> dict: + """Update properties of a review step.""" + variables = {"input": update_review_step_properties_input_mapper(data)} + mutation = get_update_review_step_properties_mutation() + result = self.graphql_client.execute(mutation, variables) + return result["data"] + + def delete_step(self, data: DeleteStepInput) -> dict: + """Delete a step from a project workflow.""" + variables = {"input": delete_step_input_mapper(data)} + mutation = get_delete_step_mutation() + result = self.graphql_client.execute(mutation, variables) + return result["data"] + + def rename_step(self, data: RenameStepInput) -> dict: + """Rename a step in a project workflow.""" + variables = {"input": rename_step_input_mapper(data)} + mutation = get_rename_step_mutation() + result = self.graphql_client.execute(mutation, variables) + return result["data"] diff --git a/src/kili/adapters/kili_api_gateway/project_workflow/types.py b/src/kili/adapters/kili_api_gateway/project_workflow/types.py index 54dfba3b5..d1d161be6 100644 --- a/src/kili/adapters/kili_api_gateway/project_workflow/types.py +++ b/src/kili/adapters/kili_api_gateway/project_workflow/types.py @@ -1,7 +1,6 @@ """Types for the ProjectWorkflow-related Kili API gateway functions.""" from dataclasses import dataclass -from typing import Optional from kili.domain.project import WorkflowStepCreate, WorkflowStepUpdate @@ -10,7 +9,60 @@ class ProjectWorkflowDataKiliAPIGatewayInput: """ProjectWorkflow input data for Kili API Gateway.""" - enforce_step_separation: Optional[bool] - create_steps: Optional[list[WorkflowStepCreate]] - update_steps: Optional[list[WorkflowStepUpdate]] - delete_steps: Optional[list[str]] + enforce_step_separation: bool | None + create_steps: list[WorkflowStepCreate] | None + update_steps: list[WorkflowStepUpdate] | None + delete_steps: list[str] | None + + +@dataclass +class AddReviewStepInput: + """Input data for adding a review step to a project workflow.""" + + project_id: str + name: str + assignees: list[str] + consensus_coverage: int | None = None + number_of_expected_labels_for_consensus: int | None = None + step_coverage: int | None = None + use_honeypot: bool | None = None + send_back_to_step: str | None = None + + +@dataclass +class UpdateLabelingStepPropertiesInput: + """Input data for updating labeling step properties.""" + + project_id: str + step_name: str + consensus_coverage: int | None = None + number_of_expected_labels_for_consensus: int | None = None + use_honeypot: bool | None = None + + +@dataclass +class UpdateReviewStepPropertiesInput: + """Input data for updating review step properties.""" + + project_id: str + step_name: str + step_coverage: int | None = None + send_back_to_step: str | None = None + use_honeypot: bool | None = None + + +@dataclass +class DeleteStepInput: + """Input data for deleting a step from a project workflow.""" + + project_id: str + step_name: str + + +@dataclass +class RenameStepInput: + """Input data for renaming a step in a project workflow.""" + + project_id: str + step_name: str + new_name: str diff --git a/src/kili/presentation/client/project_workflow.py b/src/kili/presentation/client/project_workflow.py index 5dbfcbecd..1d794e138 100644 --- a/src/kili/presentation/client/project_workflow.py +++ b/src/kili/presentation/client/project_workflow.py @@ -1,6 +1,6 @@ """Client presentation methods for project workflow.""" -from typing import Any, Optional +from typing import Any from typeguard import typechecked @@ -18,10 +18,10 @@ class ProjectWorkflowClientMethods(BaseClientMethods): def update_project_workflow( self, project_id: str, - enforce_step_separation: Optional[bool] = None, - create_steps: Optional[list[WorkflowStepCreate]] = None, - update_steps: Optional[list[WorkflowStepUpdate]] = None, - delete_steps: Optional[list[str]] = None, + enforce_step_separation: bool | None = None, + create_steps: list[WorkflowStepCreate] | None = None, + update_steps: list[WorkflowStepUpdate] | None = None, + delete_steps: list[str] | None = None, ) -> dict[str, Any]: """Update properties of a project workflow. @@ -107,3 +107,228 @@ def remove_reviewers_from_step( return ProjectWorkflowUseCases(self.kili_api_gateway).remove_reviewers_from_step( project_id=project_id, step_name=step_name, emails=emails ) + + @typechecked + def add_review_step( + self, + project_id: str, + name: str, + assignees: list[str], + consensus_coverage: int | None = None, + number_of_expected_labels_for_consensus: int | None = None, + step_coverage: int | None = None, + use_honeypot: bool | None = None, + send_back_to_step: str | None = None, + ) -> dict[str, Any]: + """Add a review step to a project workflow. + + Parameters + ---------- + project_id + Id of the project. + name + Name of the new review step. + assignees + List of user IDs to assign as reviewers. + consensus_coverage + Percentage of assets to be reviewed for consensus (0-100). + number_of_expected_labels_for_consensus + Number of expected labels for consensus. + step_coverage + Percentage of assets to be reviewed in this step (0-100). + use_honeypot + Whether to use honeypot on this step. + Only one step in the workflow can have this enabled. + send_back_to_step + Name of the step to send assets back to when rejected. + + Returns: + ------- + dict + A dict with the created step data (id, name). + """ + return ProjectWorkflowUseCases(self.kili_api_gateway).add_review_step( + project_id=project_id, + name=name, + assignees=assignees, + consensus_coverage=consensus_coverage, + number_of_expected_labels_for_consensus=number_of_expected_labels_for_consensus, + step_coverage=step_coverage, + use_honeypot=use_honeypot, + send_back_to_step=send_back_to_step, + ) + + @typechecked + def update_labeling_step_properties( + self, + project_id: str, + step_name: str, + consensus_coverage: int | None = None, + number_of_expected_labels_for_consensus: int | None = None, + use_honeypot: bool | None = None, + ) -> dict[str, Any]: + """Update properties of a labeling step. + + Parameters + ---------- + project_id + Id of the project. + step_name + Name of the labeling step to update. + consensus_coverage + Percentage of assets to be labeled for consensus (0-100). + number_of_expected_labels_for_consensus + Number of expected labels for consensus. + use_honeypot + Whether to use honeypot on this step. + Only one step in the workflow can have this enabled. + + Returns: + ------- + dict + A dict with the updated step data (id, name). + """ + return ProjectWorkflowUseCases(self.kili_api_gateway).update_labeling_step_properties( + project_id=project_id, + step_name=step_name, + consensus_coverage=consensus_coverage, + number_of_expected_labels_for_consensus=number_of_expected_labels_for_consensus, + use_honeypot=use_honeypot, + ) + + @typechecked + def update_review_step_properties( + self, + project_id: str, + step_name: str, + step_coverage: int | None = None, + send_back_to_step: str | None = None, + use_honeypot: bool | None = None, + ) -> dict[str, Any]: + """Update properties of a review step. + + Parameters + ---------- + project_id + Id of the project. + step_name + Name of the review step to update. + step_coverage + Percentage of assets to be reviewed in this step (0-100). + send_back_to_step + Name of the step to send assets back to when rejected. + use_honeypot + Whether to use honeypot on this step. + Only one step in the workflow can have this enabled. + + Returns: + ------- + dict + A dict with the updated step data (id, name). + """ + return ProjectWorkflowUseCases(self.kili_api_gateway).update_review_step_properties( + project_id=project_id, + step_name=step_name, + step_coverage=step_coverage, + send_back_to_step=send_back_to_step, + use_honeypot=use_honeypot, + ) + + @typechecked + def delete_step( + self, + project_id: str, + step_name: str, + ) -> dict[str, Any]: + """Delete a step from a project workflow. + + Parameters + ---------- + project_id + Id of the project. + step_name + Name of the step to delete. + + Returns: + ------- + dict + A dict with the deleted step data (id). + """ + return ProjectWorkflowUseCases(self.kili_api_gateway).delete_step( + project_id=project_id, + step_name=step_name, + ) + + @typechecked + def rename_step( + self, + project_id: str, + step_name: str, + new_name: str, + ) -> dict[str, Any]: + """Rename a step in a project workflow. + + Parameters + ---------- + project_id + Id of the project. + step_name + Current name of the step. + new_name + New name for the step. + + Returns: + ------- + dict + A dict with the renamed step data (id, name). + """ + return ProjectWorkflowUseCases(self.kili_api_gateway).rename_step( + project_id=project_id, + step_name=step_name, + new_name=new_name, + ) + + @typechecked + def copy_workflow_from_project( + self, + destination_project_id: str, + source_project_id: str, + ) -> dict[str, Any]: + """Copy the workflow from a source project to a destination project. + + Copies all workflow steps with their configurations (consensus, coverage, + sendBackStepId) from the source project. Assignees are not copied. + + The destination project must have no labels. Existing workflow steps in the + destination project will be deleted and replaced by the source workflow. + + Parameters + ---------- + destination_project_id + Id of the destination project to copy the workflow to. + source_project_id + Id of the source project to copy the workflow from. + + Returns: + ------- + dict + A dict with the workflow data which indicates if the mutation was successful, + else an error message. + + Raises: + ------ + ValueError + If the source project has no workflow steps, or if the destination project + already has labels. + + Examples: + -------- + >>> kili.copy_workflow_from_project( + ... destination_project_id="destination_project_id", + ... source_project_id="source_project_id", + ... ) + """ + return ProjectWorkflowUseCases(self.kili_api_gateway).copy_workflow_from_project( + source_project_id=ProjectId(source_project_id), + destination_project_id=ProjectId(destination_project_id), + ) diff --git a/src/kili/use_cases/project_workflow/__init__.py b/src/kili/use_cases/project_workflow/__init__.py index 248666697..a21107294 100644 --- a/src/kili/use_cases/project_workflow/__init__.py +++ b/src/kili/use_cases/project_workflow/__init__.py @@ -1,14 +1,32 @@ """Project use cases.""" -from typing import Optional +import logging from kili.adapters.kili_api_gateway.project_workflow.types import ( + AddReviewStepInput, + DeleteStepInput, ProjectWorkflowDataKiliAPIGatewayInput, + RenameStepInput, + UpdateLabelingStepPropertiesInput, + UpdateReviewStepPropertiesInput, ) +from kili.domain.label import LabelFilters from kili.domain.project import ProjectId, WorkflowStepCreate, WorkflowStepUpdate from kili.domain.types import ListOrTuple from kili.use_cases.base import BaseUseCases +logger = logging.getLogger(__name__) + +_SOURCE_STEP_FIELDS = ( + "steps.id", + "steps.name", + "steps.type", + "steps.consensusCoverage", + "steps.numberOfExpectedLabelsForConsensus", + "steps.stepCoverage", + "steps.sendBackStepId", +) + class ProjectWorkflowUseCases(BaseUseCases): """ProjectWorkflow use cases.""" @@ -16,10 +34,10 @@ class ProjectWorkflowUseCases(BaseUseCases): def update_project_workflow( self, project_id: ProjectId, - enforce_step_separation: Optional[bool] = None, - create_steps: Optional[list[WorkflowStepCreate]] = None, - update_steps: Optional[list[WorkflowStepUpdate]] = None, - delete_steps: Optional[list[str]] = None, + enforce_step_separation: bool | None = None, + create_steps: list[WorkflowStepCreate] | None = None, + update_steps: list[WorkflowStepUpdate] | None = None, + delete_steps: list[str] | None = None, ) -> dict[str, object]: """Update properties in a project workflow.""" project_workflow_data = ProjectWorkflowDataKiliAPIGatewayInput( @@ -50,3 +68,237 @@ def remove_reviewers_from_step( ) -> list[str]: """Remove reviewers from a specific step.""" return self._kili_api_gateway.remove_reviewers_from_step(project_id, step_name, emails) + + def add_review_step( + self, + project_id: str, + name: str, + assignees: list[str], + consensus_coverage: int | None = None, + number_of_expected_labels_for_consensus: int | None = None, + step_coverage: int | None = None, + use_honeypot: bool | None = None, + send_back_to_step: str | None = None, + ) -> dict[str, object]: + """Add a review step to a project workflow.""" + data = AddReviewStepInput( + project_id=project_id, + name=name, + assignees=assignees, + consensus_coverage=consensus_coverage, + number_of_expected_labels_for_consensus=number_of_expected_labels_for_consensus, + step_coverage=step_coverage, + use_honeypot=use_honeypot, + send_back_to_step=send_back_to_step, + ) + return self._kili_api_gateway.add_review_step(data) + + def update_labeling_step_properties( + self, + project_id: str, + step_name: str, + consensus_coverage: int | None = None, + number_of_expected_labels_for_consensus: int | None = None, + use_honeypot: bool | None = None, + ) -> dict[str, object]: + """Update properties of a labeling step.""" + data = UpdateLabelingStepPropertiesInput( + project_id=project_id, + step_name=step_name, + consensus_coverage=consensus_coverage, + number_of_expected_labels_for_consensus=number_of_expected_labels_for_consensus, + use_honeypot=use_honeypot, + ) + return self._kili_api_gateway.update_labeling_step_properties(data) + + def update_review_step_properties( + self, + project_id: str, + step_name: str, + step_coverage: int | None = None, + send_back_to_step: str | None = None, + use_honeypot: bool | None = None, + ) -> dict[str, object]: + """Update properties of a review step.""" + data = UpdateReviewStepPropertiesInput( + project_id=project_id, + step_name=step_name, + step_coverage=step_coverage, + send_back_to_step=send_back_to_step, + use_honeypot=use_honeypot, + ) + return self._kili_api_gateway.update_review_step_properties(data) + + def delete_step( + self, + project_id: str, + step_name: str, + ) -> dict[str, object]: + """Delete a step from a project workflow.""" + data = DeleteStepInput( + project_id=project_id, + step_name=step_name, + ) + return self._kili_api_gateway.delete_step(data) + + def rename_step( + self, + project_id: str, + step_name: str, + new_name: str, + ) -> dict[str, object]: + """Rename a step in a project workflow.""" + data = RenameStepInput( + project_id=project_id, + step_name=step_name, + new_name=new_name, + ) + return self._kili_api_gateway.rename_step(data) + + def copy_workflow_from_project( + self, + source_project_id: ProjectId, + destination_project_id: ProjectId, + ) -> dict[str, object]: + """Copy a workflow from one project to another. + + Fetches the source workflow steps, validates the destination project has no labels, + deletes existing destination steps, and creates new steps with remapped sendBackStepId + references. + """ + # 1. Fetch source workflow steps + logger.info("Fetching workflow steps from source project %s", source_project_id) + source_steps = self._kili_api_gateway.get_steps(source_project_id, _SOURCE_STEP_FIELDS) + + if not source_steps: + raise ValueError(f"Source project {source_project_id} has no workflow steps to copy.") + + # 2. Validate destination has no labels + logger.info( + "Validating destination project %s has no labels", + destination_project_id, + ) + label_count = self._kili_api_gateway.count_labels( + filters=LabelFilters(project_id=destination_project_id) + ) + if label_count > 0: + raise ValueError( + f"Destination project {destination_project_id} already has" + f" {label_count} label(s). Cannot copy workflow to a project" + " that has already been labeled." + ) + + # 3. Delete existing destination steps + logger.info( + "Fetching existing steps from destination project %s", + destination_project_id, + ) + try: + dest_steps = self._kili_api_gateway.get_steps( + destination_project_id, ("steps.id", "steps.name") + ) + except Exception: + dest_steps = [] + + dest_step_ids = [step["id"] for step in dest_steps] + + # 4. Build create steps + steps_to_create: list[WorkflowStepCreate] = [] + source_steps_with_send_back: list[tuple[int, str]] = [] + + for idx, step in enumerate(source_steps): + create_step: WorkflowStepCreate = { + "name": step["name"], + "type": step["type"], + "assignees": [], # Don't copy assignees per spec + } + if step.get("consensusCoverage") is not None: + create_step["consensus_coverage"] = step["consensusCoverage"] + if step.get("numberOfExpectedLabelsForConsensus") is not None: + create_step["number_of_expected_labels_for_consensus"] = step[ + "numberOfExpectedLabelsForConsensus" + ] + if step.get("stepCoverage") is not None: + create_step["step_coverage"] = step["stepCoverage"] + + # Track steps that have sendBackStepId for remapping later + if step.get("sendBackStepId"): + source_steps_with_send_back.append((idx, step["sendBackStepId"])) + + steps_to_create.append(create_step) + + # 5. Execute: delete existing + create new steps in a single call + logger.info( + "Copying %d steps from project %s to project %s", + len(steps_to_create), + source_project_id, + destination_project_id, + ) + + result = self._kili_api_gateway.update_project_workflow( + destination_project_id, + ProjectWorkflowDataKiliAPIGatewayInput( + enforce_step_separation=None, + create_steps=steps_to_create, + update_steps=None, + delete_steps=dest_step_ids if dest_step_ids else None, + ), + ) + + # 6. Remap sendBackStepId references if any steps had them + if source_steps_with_send_back: + logger.info("Remapping sendBackStepId references for copied steps") + + # Build mapping from source step ID to index + source_id_to_idx = {step["id"]: idx for idx, step in enumerate(source_steps)} + + # Get newly created step IDs from the destination + new_steps = self._kili_api_gateway.get_steps( + destination_project_id, ("steps.id", "steps.name") + ) + + # Build mapping from step name to new step ID + name_to_new_id = {step["name"]: step["id"] for step in new_steps} + idx_to_name = {idx: step["name"] for idx, step in enumerate(source_steps)} + + # Build update steps for sendBackStepId remapping + updates_for_send_back: list[WorkflowStepUpdate] = [] + for step_idx, source_send_back_id in source_steps_with_send_back: + target_source_idx = source_id_to_idx.get(source_send_back_id) + if target_source_idx is None: + logger.warning( + "Could not find source step for sendBackStepId %s, skipping", + source_send_back_id, + ) + continue + + step_name = idx_to_name[step_idx] + target_name = idx_to_name[target_source_idx] + new_step_id = name_to_new_id.get(step_name) + new_target_id = name_to_new_id.get(target_name) + + if new_step_id and new_target_id: + updates_for_send_back.append( + { + "id": new_step_id, + "send_back_step_id": new_target_id, + } + ) + + if updates_for_send_back: + result = self._kili_api_gateway.update_project_workflow( + destination_project_id, + ProjectWorkflowDataKiliAPIGatewayInput( + enforce_step_separation=None, + create_steps=None, + update_steps=updates_for_send_back, + delete_steps=None, + ), + ) + + logger.info( + "Successfully copied workflow from project %s to project %s", + source_project_id, + destination_project_id, + ) + return result