Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/uipath-platform/src/uipath/platform/common/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@
from ._user_agent import user_agent_value
from .auth import TokenData
from .dynamic_schema import jsonschema_to_pydantic
from ..hitl import (
HitlFieldDirection,
HitlFieldType,
HitlSchema,
HitlSchemaField,
HitlSchemaOutcome,
pydantic_to_hitl_schema,
)
from .interrupt_models import (
CreateBatchTransform,
CreateDeepRag,
Expand Down Expand Up @@ -102,6 +110,12 @@
"validate_pagination_params",
"EndpointManager",
"jsonschema_to_pydantic",
"HitlFieldDirection",
"HitlFieldType",
"HitlSchema",
"HitlSchemaField",
"HitlSchemaOutcome",
"pydantic_to_hitl_schema",
"ConnectionResourceOverwrite",
"EntityResourceOverwrite",
"GenericResourceOverwrite",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from ..action_center.tasks import Task, TaskRecipient
from ..attachments import Attachment
from ..hitl.models import HitlSchema
from ..context_grounding import (
BatchTransformCreationResponse,
BatchTransformOutputColumn,
Expand Down Expand Up @@ -58,7 +59,12 @@ class WaitJobRaw(WaitJob):


class CreateTask(BaseModel):
"""Model representing an action creation."""
"""Model representing an action creation.

When *schema* is set the runtime creates a schema-driven **QuickForm** task
(``GenericTasks/CreateTask``, ``type=6``) instead of an action-app task.
No pre-deployed Action App is required in that case.
"""

title: str
data: dict[str, Any] | None = None
Expand All @@ -73,6 +79,7 @@ class CreateTask(BaseModel):
is_actionable_message_enabled: bool | None = None
actionable_message_metadata: dict[str, Any] | None = None
source_name: str = "Agent"
hitl_schema: HitlSchema | None = None


class CreateEscalation(CreateTask):
Expand Down
47 changes: 47 additions & 0 deletions packages/uipath-platform/src/uipath/platform/hitl/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""HITL form schema types and generator.

Provides Python-native representations of the HITL schema format and a utility
to generate schemas from Pydantic models. The generated schema can be attached
to :class:`~uipath.platform.common.CreateTask` or
:class:`~uipath.platform.common.CreateEscalation` so the runtime creates a
QuickForm task with an inline schema instead of requiring a pre-deployed
Action App.

Example::

from uipath.platform.hitl import HitlSchema, pydantic_to_hitl_schema
from pydantic import BaseModel, Field

class ReviewInputs(BaseModel):
flagged_content: str = Field(title="Flagged Content")
reason: str = Field(title="Reason")

class ReviewOutputs(BaseModel):
decision: str = Field(default="", title="Decision")
notes: str = Field(default="", title="Notes")

schema = pydantic_to_hitl_schema(
input_model=ReviewInputs,
output_model=ReviewOutputs,
outcomes=["Approve", "Reject"],
title="Content Review",
)
"""

from .models import (
HitlFieldDirection,
HitlFieldType,
HitlSchema,
HitlSchemaField,
HitlSchemaOutcome,
)
from .schema_gen import pydantic_to_hitl_schema

__all__ = [
"HitlFieldDirection",
"HitlFieldType",
"HitlSchema",
"HitlSchemaField",
"HitlSchemaOutcome",
"pydantic_to_hitl_schema",
]
76 changes: 76 additions & 0 deletions packages/uipath-platform/src/uipath/platform/hitl/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Python representation of the HITL form schema format.

Mirrors the TypeScript ``HitlSchema`` interface from ``@uipath/hitl-schema-types``
and is compatible with the QuickForm task endpoint
(``GenericTasks/CreateTask``, ``type=6``).
"""

from enum import Enum
from typing import Any

from pydantic import BaseModel, Field


class HitlFieldType(str, Enum):
"""Types supported by HITL form fields."""

STRING = "string"
NUMBER = "number"
INTEGER = "integer"
FLOAT = "float"
DOUBLE = "double"
BOOLEAN = "boolean"
DATE = "date"
DATETIME = "datetime"
FILE = "file"
OBJECT = "object"
ARRAY = "array"


class HitlFieldDirection(str, Enum):
"""Direction of a HITL form field.

- ``INPUT`` — read-only display, pre-populated from task data.
- ``OUTPUT`` — editable by the human reviewer, written to a variable on submit.
- ``IN_OUT`` — both pre-populated and editable.
"""

INPUT = "input"
OUTPUT = "output"
IN_OUT = "inOut"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we have this case?
Generarly at pre target execution, the input field is editable.
At post target execution, we show both fields (input and output), but only output is editable. Doesn't make sense to edit input since it is allready consumed by llm/tool.



class HitlSchemaField(BaseModel):
"""A single field in a HITL form schema."""

id: str
label: str | None = None
type: HitlFieldType = HitlFieldType.STRING
direction: HitlFieldDirection = HitlFieldDirection.INPUT
required: bool | None = None


class HitlSchemaOutcome(BaseModel):
"""An outcome button in a HITL form schema (e.g. Approve / Reject)."""

id: str
label: str | None = None


class HitlSchema(BaseModel):
"""A dynamic HITL form schema generated from Python type annotations.

Attach to :class:`~uipath.platform.common.CreateTask` or
:class:`~uipath.platform.common.CreateEscalation` so the runtime creates a
schema-driven QuickForm task instead of an action-app task. No pre-deployed
Action App is required when a schema is provided.
"""

id: str | None = None
title: str | None = None
fields: list[HitlSchemaField] = Field(default_factory=list)
outcomes: list[HitlSchemaOutcome] = Field(default_factory=list)

def to_wire_format(self) -> dict[str, Any]:
"""Serialise to the dict expected by the QuickForm task API."""
return self.model_dump(mode="json", exclude_none=True)
143 changes: 143 additions & 0 deletions packages/uipath-platform/src/uipath/platform/hitl/schema_gen.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""Generate a :class:`HitlSchema` from Python type annotations.

The primary entry point is :func:`pydantic_to_hitl_schema`, which converts
Pydantic ``BaseModel`` classes into the :class:`HitlSchema` format consumed by
Action Center's QuickForm task endpoint.
"""

from __future__ import annotations

import inspect
import re
from types import UnionType
from typing import Any, Union, get_args, get_origin

from pydantic import BaseModel

from .models import (
HitlFieldDirection,
HitlFieldType,
HitlSchema,
HitlSchemaField,
HitlSchemaOutcome,
)


def _annotation_to_hitl_type(annotation: Any) -> HitlFieldType:

Check failure on line 26 in packages/uipath-platform/src/uipath/platform/hitl/schema_gen.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=UiPath_uipath-python&issues=AZ7taLmd4ZKJmrimjM6f&open=AZ7taLmd4ZKJmrimjM6f&pullRequest=1737
"""Map a Python type annotation to the closest :class:`HitlFieldType`."""
if annotation is None or annotation is inspect.Parameter.empty:
return HitlFieldType.STRING

origin = get_origin(annotation)
args = get_args(annotation)

# Handle Optional[T] / T | None (both typing.Union and PEP 604 UnionType)
if origin is Union or isinstance(annotation, UnionType):
non_none = [a for a in args if a is not type(None)]
if non_none:
return _annotation_to_hitl_type(non_none[0])
return HitlFieldType.STRING

if origin in (list, tuple):
return HitlFieldType.ARRAY
if origin is dict:
return HitlFieldType.OBJECT

if not inspect.isclass(annotation):
return HitlFieldType.STRING

# Check bool before int — bool is a subclass of int
if issubclass(annotation, bool):
return HitlFieldType.BOOLEAN
if issubclass(annotation, int):
return HitlFieldType.INTEGER
if issubclass(annotation, float):
return HitlFieldType.NUMBER
if issubclass(annotation, str):
return HitlFieldType.STRING
if issubclass(annotation, (list, tuple)):
return HitlFieldType.ARRAY
if issubclass(annotation, dict):
return HitlFieldType.OBJECT
if issubclass(annotation, BaseModel):
return HitlFieldType.OBJECT

return HitlFieldType.STRING


def _label_from_name(name: str) -> str:
"""Produce a human-readable label from a snake_case or PascalCase name."""
# PascalCase → insert space before each capital that follows a lowercase letter
spaced = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", name)
# Underscores → spaces
spaced = spaced.replace("_", " ")
return spaced.title()


def pydantic_to_hitl_schema(
*,
input_model: type[BaseModel] | None = None,
output_model: type[BaseModel] | None = None,
outcomes: list[str] | None = None,
title: str | None = None,
) -> HitlSchema:
"""Generate a :class:`HitlSchema` from Pydantic input and output models.

Fields from *input_model* become ``direction="input"`` fields (read-only,
pre-populated from task data). Fields from *output_model* become
``direction="output"`` fields (editable by the human reviewer).

The field ``id`` is taken from ``field_info.alias`` when one is set,
otherwise from the Python field name. Set an alias on a Pydantic field
to control the id that Action Center uses (useful when the form contract
requires PascalCase names but Python convention is snake_case).

Args:
input_model: Pydantic model for data shown to the reviewer.
output_model: Pydantic model for data the reviewer fills in.
outcomes: Outcome button labels. Defaults to ``["Approve", "Reject"]``.
title: Human-readable title for the form.

Returns:
A :class:`HitlSchema` ready to attach to
:class:`~uipath.platform.common.CreateTask`.
"""
if outcomes is None:
outcomes = ["Approve", "Reject"]

fields: list[HitlSchemaField] = []

if input_model is not None:
for name, field_info in input_model.model_fields.items():
field_id = field_info.alias or name
fields.append(
HitlSchemaField(
id=str(field_id),
label=field_info.title or _label_from_name(str(field_id)),
type=_annotation_to_hitl_type(field_info.annotation),
direction=HitlFieldDirection.INPUT,
required=True if field_info.is_required() else None,
)
)

if output_model is not None:
for name, field_info in output_model.model_fields.items():
field_id = field_info.alias or name
fields.append(
HitlSchemaField(
id=str(field_id),
label=field_info.title or _label_from_name(str(field_id)),
type=_annotation_to_hitl_type(field_info.annotation),
direction=HitlFieldDirection.OUTPUT,
required=None,
)
)

return HitlSchema(
title=title,
fields=fields,
outcomes=[
HitlSchemaOutcome(id=outcome.lower(), label=outcome)
for outcome in outcomes
],
)
Loading
Loading