Skip to content

Commit cfc5b8e

Browse files
feat: add HitlSchema types and dynamic schema dispatch for QuickForm tasks
Adds a new `hitl/` module to `uipath-platform` with `HitlSchema`, `HitlSchemaField`, `HitlSchemaOutcome`, `HitlFieldType`, and `HitlFieldDirection` — a Python mirror of `@uipath/hitl-schema-types`. `pydantic_to_hitl_schema()` converts Pydantic models into `HitlSchema` instances. Field aliases become the schema field IDs, enabling PascalCase form field names while keeping Python field names snake_case. `CreateTask` gains an optional `hitl_schema` field. When set, `_handle_task_trigger` routes to `create_quickform_async` instead of `create_async`, sending the schema inline to `GenericTasks/CreateTask` (type=6). A deterministic UUID derived from the schema content is used as `taskSchemaKey` so Orchestrator can upsert rather than duplicate. No pre-deployed Action App is required when a schema is provided.
1 parent b2b3608 commit cfc5b8e

7 files changed

Lines changed: 539 additions & 16 deletions

File tree

packages/uipath-platform/src/uipath/platform/common/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@
2727
from ._user_agent import user_agent_value
2828
from .auth import TokenData
2929
from .dynamic_schema import jsonschema_to_pydantic
30+
from ..hitl import (
31+
HitlFieldDirection,
32+
HitlFieldType,
33+
HitlSchema,
34+
HitlSchemaField,
35+
HitlSchemaOutcome,
36+
pydantic_to_hitl_schema,
37+
)
3038
from .interrupt_models import (
3139
CreateBatchTransform,
3240
CreateDeepRag,
@@ -102,6 +110,12 @@
102110
"validate_pagination_params",
103111
"EndpointManager",
104112
"jsonschema_to_pydantic",
113+
"HitlFieldDirection",
114+
"HitlFieldType",
115+
"HitlSchema",
116+
"HitlSchemaField",
117+
"HitlSchemaOutcome",
118+
"pydantic_to_hitl_schema",
105119
"ConnectionResourceOverwrite",
106120
"EntityResourceOverwrite",
107121
"GenericResourceOverwrite",

packages/uipath-platform/src/uipath/platform/common/interrupt_models.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from ..action_center.tasks import Task, TaskRecipient
1212
from ..attachments import Attachment
13+
from ..hitl.models import HitlSchema
1314
from ..context_grounding import (
1415
BatchTransformCreationResponse,
1516
BatchTransformOutputColumn,
@@ -58,7 +59,12 @@ class WaitJobRaw(WaitJob):
5859

5960

6061
class CreateTask(BaseModel):
61-
"""Model representing an action creation."""
62+
"""Model representing an action creation.
63+
64+
When *schema* is set the runtime creates a schema-driven **QuickForm** task
65+
(``GenericTasks/CreateTask``, ``type=6``) instead of an action-app task.
66+
No pre-deployed Action App is required in that case.
67+
"""
6268

6369
title: str
6470
data: dict[str, Any] | None = None
@@ -73,6 +79,7 @@ class CreateTask(BaseModel):
7379
is_actionable_message_enabled: bool | None = None
7480
actionable_message_metadata: dict[str, Any] | None = None
7581
source_name: str = "Agent"
82+
hitl_schema: HitlSchema | None = None
7683

7784

7885
class CreateEscalation(CreateTask):
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""HITL form schema types and generator.
2+
3+
Provides Python-native representations of the HITL schema format and a utility
4+
to generate schemas from Pydantic models. The generated schema can be attached
5+
to :class:`~uipath.platform.common.CreateTask` or
6+
:class:`~uipath.platform.common.CreateEscalation` so the runtime creates a
7+
QuickForm task with an inline schema instead of requiring a pre-deployed
8+
Action App.
9+
10+
Example::
11+
12+
from uipath.platform.hitl import HitlSchema, pydantic_to_hitl_schema
13+
from pydantic import BaseModel, Field
14+
15+
class ReviewInputs(BaseModel):
16+
flagged_content: str = Field(title="Flagged Content")
17+
reason: str = Field(title="Reason")
18+
19+
class ReviewOutputs(BaseModel):
20+
decision: str = Field(default="", title="Decision")
21+
notes: str = Field(default="", title="Notes")
22+
23+
schema = pydantic_to_hitl_schema(
24+
input_model=ReviewInputs,
25+
output_model=ReviewOutputs,
26+
outcomes=["Approve", "Reject"],
27+
title="Content Review",
28+
)
29+
"""
30+
31+
from .models import (
32+
HitlFieldDirection,
33+
HitlFieldType,
34+
HitlSchema,
35+
HitlSchemaField,
36+
HitlSchemaOutcome,
37+
)
38+
from .schema_gen import pydantic_to_hitl_schema
39+
40+
__all__ = [
41+
"HitlFieldDirection",
42+
"HitlFieldType",
43+
"HitlSchema",
44+
"HitlSchemaField",
45+
"HitlSchemaOutcome",
46+
"pydantic_to_hitl_schema",
47+
]
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Python representation of the HITL form schema format.
2+
3+
Mirrors the TypeScript ``HitlSchema`` interface from ``@uipath/hitl-schema-types``
4+
and is compatible with the QuickForm task endpoint
5+
(``GenericTasks/CreateTask``, ``type=6``).
6+
"""
7+
8+
from enum import Enum
9+
from typing import Any
10+
11+
from pydantic import BaseModel, Field
12+
13+
14+
class HitlFieldType(str, Enum):
15+
"""Types supported by HITL form fields."""
16+
17+
STRING = "string"
18+
NUMBER = "number"
19+
INTEGER = "integer"
20+
FLOAT = "float"
21+
DOUBLE = "double"
22+
BOOLEAN = "boolean"
23+
DATE = "date"
24+
DATETIME = "datetime"
25+
FILE = "file"
26+
OBJECT = "object"
27+
ARRAY = "array"
28+
29+
30+
class HitlFieldDirection(str, Enum):
31+
"""Direction of a HITL form field.
32+
33+
- ``INPUT`` — read-only display, pre-populated from task data.
34+
- ``OUTPUT`` — editable by the human reviewer, written to a variable on submit.
35+
- ``IN_OUT`` — both pre-populated and editable.
36+
"""
37+
38+
INPUT = "input"
39+
OUTPUT = "output"
40+
IN_OUT = "inOut"
41+
42+
43+
class HitlSchemaField(BaseModel):
44+
"""A single field in a HITL form schema."""
45+
46+
id: str
47+
label: str | None = None
48+
type: HitlFieldType = HitlFieldType.STRING
49+
direction: HitlFieldDirection = HitlFieldDirection.INPUT
50+
required: bool | None = None
51+
52+
53+
class HitlSchemaOutcome(BaseModel):
54+
"""An outcome button in a HITL form schema (e.g. Approve / Reject)."""
55+
56+
id: str
57+
label: str | None = None
58+
59+
60+
class HitlSchema(BaseModel):
61+
"""A dynamic HITL form schema generated from Python type annotations.
62+
63+
Attach to :class:`~uipath.platform.common.CreateTask` or
64+
:class:`~uipath.platform.common.CreateEscalation` so the runtime creates a
65+
schema-driven QuickForm task instead of an action-app task. No pre-deployed
66+
Action App is required when a schema is provided.
67+
"""
68+
69+
id: str | None = None
70+
title: str | None = None
71+
fields: list[HitlSchemaField] = Field(default_factory=list)
72+
outcomes: list[HitlSchemaOutcome] = Field(default_factory=list)
73+
74+
def to_wire_format(self) -> dict[str, Any]:
75+
"""Serialise to the dict expected by the QuickForm task API."""
76+
return self.model_dump(mode="json", exclude_none=True)
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""Generate a :class:`HitlSchema` from Python type annotations.
2+
3+
The primary entry point is :func:`pydantic_to_hitl_schema`, which converts
4+
Pydantic ``BaseModel`` classes into the :class:`HitlSchema` format consumed by
5+
Action Center's QuickForm task endpoint.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import inspect
11+
import re
12+
from types import UnionType
13+
from typing import Any, Union, get_args, get_origin
14+
15+
from pydantic import BaseModel
16+
17+
from .models import (
18+
HitlFieldDirection,
19+
HitlFieldType,
20+
HitlSchema,
21+
HitlSchemaField,
22+
HitlSchemaOutcome,
23+
)
24+
25+
26+
def _annotation_to_hitl_type(annotation: Any) -> HitlFieldType:
27+
"""Map a Python type annotation to the closest :class:`HitlFieldType`."""
28+
if annotation is None or annotation is inspect.Parameter.empty:
29+
return HitlFieldType.STRING
30+
31+
origin = get_origin(annotation)
32+
args = get_args(annotation)
33+
34+
# Handle Optional[T] / T | None (both typing.Union and PEP 604 UnionType)
35+
if origin is Union or isinstance(annotation, UnionType):
36+
non_none = [a for a in args if a is not type(None)]
37+
if non_none:
38+
return _annotation_to_hitl_type(non_none[0])
39+
return HitlFieldType.STRING
40+
41+
if origin in (list, tuple):
42+
return HitlFieldType.ARRAY
43+
if origin is dict:
44+
return HitlFieldType.OBJECT
45+
46+
if not inspect.isclass(annotation):
47+
return HitlFieldType.STRING
48+
49+
# Check bool before int — bool is a subclass of int
50+
if issubclass(annotation, bool):
51+
return HitlFieldType.BOOLEAN
52+
if issubclass(annotation, int):
53+
return HitlFieldType.INTEGER
54+
if issubclass(annotation, float):
55+
return HitlFieldType.NUMBER
56+
if issubclass(annotation, str):
57+
return HitlFieldType.STRING
58+
if issubclass(annotation, (list, tuple)):
59+
return HitlFieldType.ARRAY
60+
if issubclass(annotation, dict):
61+
return HitlFieldType.OBJECT
62+
if issubclass(annotation, BaseModel):
63+
return HitlFieldType.OBJECT
64+
65+
return HitlFieldType.STRING
66+
67+
68+
def _label_from_name(name: str) -> str:
69+
"""Produce a human-readable label from a snake_case or PascalCase name."""
70+
# PascalCase → insert space before each capital that follows a lowercase letter
71+
spaced = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", name)
72+
# Underscores → spaces
73+
spaced = spaced.replace("_", " ")
74+
return spaced.title()
75+
76+
77+
def pydantic_to_hitl_schema(
78+
*,
79+
input_model: type[BaseModel] | None = None,
80+
output_model: type[BaseModel] | None = None,
81+
outcomes: list[str] | None = None,
82+
title: str | None = None,
83+
) -> HitlSchema:
84+
"""Generate a :class:`HitlSchema` from Pydantic input and output models.
85+
86+
Fields from *input_model* become ``direction="input"`` fields (read-only,
87+
pre-populated from task data). Fields from *output_model* become
88+
``direction="output"`` fields (editable by the human reviewer).
89+
90+
The field ``id`` is taken from ``field_info.alias`` when one is set,
91+
otherwise from the Python field name. Set an alias on a Pydantic field
92+
to control the id that Action Center uses (useful when the form contract
93+
requires PascalCase names but Python convention is snake_case).
94+
95+
Args:
96+
input_model: Pydantic model for data shown to the reviewer.
97+
output_model: Pydantic model for data the reviewer fills in.
98+
outcomes: Outcome button labels. Defaults to ``["Approve", "Reject"]``.
99+
title: Human-readable title for the form.
100+
101+
Returns:
102+
A :class:`HitlSchema` ready to attach to
103+
:class:`~uipath.platform.common.CreateTask`.
104+
"""
105+
if outcomes is None:
106+
outcomes = ["Approve", "Reject"]
107+
108+
fields: list[HitlSchemaField] = []
109+
110+
if input_model is not None:
111+
for name, field_info in input_model.model_fields.items():
112+
field_id = field_info.alias or name
113+
fields.append(
114+
HitlSchemaField(
115+
id=str(field_id),
116+
label=field_info.title or _label_from_name(str(field_id)),
117+
type=_annotation_to_hitl_type(field_info.annotation),
118+
direction=HitlFieldDirection.INPUT,
119+
required=True if field_info.is_required() else None,
120+
)
121+
)
122+
123+
if output_model is not None:
124+
for name, field_info in output_model.model_fields.items():
125+
field_id = field_info.alias or name
126+
fields.append(
127+
HitlSchemaField(
128+
id=str(field_id),
129+
label=field_info.title or _label_from_name(str(field_id)),
130+
type=_annotation_to_hitl_type(field_info.annotation),
131+
direction=HitlFieldDirection.OUTPUT,
132+
required=None,
133+
)
134+
)
135+
136+
return HitlSchema(
137+
title=title,
138+
fields=fields,
139+
outcomes=[
140+
HitlSchemaOutcome(id=outcome.lower(), label=outcome)
141+
for outcome in outcomes
142+
],
143+
)

0 commit comments

Comments
 (0)