From 713fe9cf9c59add7df72ca18af5eb7469db82e97 Mon Sep 17 00:00:00 2001 From: Andrei Petraru Date: Wed, 8 Jul 2026 10:57:26 +0300 Subject: [PATCH] feat(guardrails): add LLMAsJudgeValidator for the @guardrail decorator [AL-470] Adds an llm_as_judge built-in validator to the coded-agent decorator path, closing the last gap (low-code and the guardrail middleware already support it). - LLMAsJudgeValidator(BuiltInGuardrailValidator) builds a BuiltInValidatorGuardrail with validator_type="llm_as_judge" and parameters guardrailText (text), model (enum), threshold (number, 0-6, default 2), plus optional positiveExamples/negativeExamples (text-list). No scope/action on the validator (those are @guardrail args); selector stays None (scope comes from the wrapped target); all stages supported. - Exported through validators/__init__, decorators/__init__, and platform/guardrails/__init__. - Tests: construction/params/validation (test_llm_as_judge_validator.py) and decorator-level block/log behavior including a real-validator, mocked-backend case (test_guardrails_decorators.py). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../uipath/platform/guardrails/__init__.py | 2 + .../guardrails/decorators/__init__.py | 2 + .../decorators/validators/__init__.py | 2 + .../decorators/validators/llm_as_judge.py | 157 ++++++++++++++++++ .../services/test_guardrails_decorators.py | 67 ++++++++ .../services/test_llm_as_judge_validator.py | 142 ++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 9 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/llm_as_judge.py create mode 100644 packages/uipath-platform/tests/services/test_llm_as_judge_validator.py diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index d2a0f4efc..72b0d0b3a 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.4" +version = "0.2.5" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py b/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py index 93c60b0a4..ed6c8d4dd 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py @@ -30,6 +30,7 @@ HarmfulContentValidator, IntellectualPropertyEntityType, IntellectualPropertyValidator, + LLMAsJudgeValidator, LogAction, LoggingSeverityLevel, PIIDetectionEntity, @@ -69,6 +70,7 @@ "CustomGuardrailValidator", "HarmfulContentValidator", "IntellectualPropertyValidator", + "LLMAsJudgeValidator", "PIIValidator", "PromptInjectionValidator", "UserPromptAttacksValidator", diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/__init__.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/__init__.py index e8d692164..841da6660 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/__init__.py @@ -24,6 +24,7 @@ GuardrailValidatorBase, HarmfulContentValidator, IntellectualPropertyValidator, + LLMAsJudgeValidator, PIIValidator, PromptInjectionValidator, RuleFunction, @@ -39,6 +40,7 @@ "CustomGuardrailValidator", "HarmfulContentValidator", "IntellectualPropertyValidator", + "LLMAsJudgeValidator", "PIIValidator", "PromptInjectionValidator", "UserPromptAttacksValidator", diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/__init__.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/__init__.py index bbcf29039..14cea0d4b 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/__init__.py @@ -8,6 +8,7 @@ from .custom import CustomValidator, RuleFunction from .harmful_content import HarmfulContentValidator from .intellectual_property import IntellectualPropertyValidator +from .llm_as_judge import LLMAsJudgeValidator from .pii import PIIValidator from .prompt_injection import PromptInjectionValidator from .user_prompt_attacks import UserPromptAttacksValidator @@ -18,6 +19,7 @@ "CustomGuardrailValidator", "HarmfulContentValidator", "IntellectualPropertyValidator", + "LLMAsJudgeValidator", "PIIValidator", "PromptInjectionValidator", "UserPromptAttacksValidator", diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/llm_as_judge.py b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/llm_as_judge.py new file mode 100644 index 000000000..f639a6ef3 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/decorators/validators/llm_as_judge.py @@ -0,0 +1,157 @@ +"""LLM-as-judge guardrail validator.""" + +from typing import Any, Sequence +from uuid import uuid4 + +from uipath.platform.guardrails.guardrails import ( + BuiltInValidatorGuardrail, + EnumParameterValue, + NumberParameterValue, + TextListParameterValue, + TextParameterValue, +) + +from ._base import BuiltInGuardrailValidator + +# Threshold scale matches the backend OOTB catalog: any float in [0, 6], default 2 +# (the catalog's UI step of 2 is only a slider increment; the server accepts any +# value in range). HIGHER = more lenient (only flag clear violations), LOWER = stricter. +_THRESHOLD_MIN = 0.0 +_THRESHOLD_MAX = 6.0 +_THRESHOLD_DEFAULT = 2.0 + +# Input limits mirror the backend OOTB catalog / LlmAsJudgeProviderApi; keep in sync. +_MAX_GUARDRAIL_TEXT_LENGTH = 4000 +_MAX_EXAMPLE_LENGTH = 1000 +_MAX_EXAMPLES_PER_LIST = 2 + + +class LLMAsJudgeValidator(BuiltInGuardrailValidator): + """Validate content against a natural-language rule via an LLM judge. + + The customer expresses a rule in natural language (``guardrail_text``) and picks a + judge model; a judge LLM decides whether the evaluated payload complies. Works at + any scope/stage the ``@guardrail`` decorator wraps (scope is implicit in the + decorated target; ``supported_stages`` is left as the default empty list, which + means all stages are allowed — both PRE and POST). + + Args: + guardrail_text: The natural-language rule the judge evaluates against + (at most 4000 characters). + model: The judge model to use (a model id supported by LLM Gateway). + positive_examples: Optional example payloads that comply with the rule + (at most 2 entries, each at most 1000 characters). + negative_examples: Optional example payloads that violate the rule + (at most 2 entries, each at most 1000 characters). + threshold: Strictness on a 0-6 scale (default 2); higher is more lenient. + + Raises: + ValueError: If ``guardrail_text``/``model`` are empty, ``threshold`` is + outside [0, 6], ``guardrail_text`` exceeds 4000 characters, either example + list has more than 2 entries, or any example exceeds 1000 characters. + """ + + def __init__( + self, + guardrail_text: str, + model: str, + *, + positive_examples: Sequence[str] | None = None, + negative_examples: Sequence[str] | None = None, + threshold: float = _THRESHOLD_DEFAULT, + ) -> None: + """Initialize LLMAsJudgeValidator with the rule, judge model, and options.""" + if not guardrail_text or not guardrail_text.strip(): + raise ValueError("guardrail_text must be a non-empty string") + if not model or not model.strip(): + raise ValueError("model must be a non-empty string") + if not _THRESHOLD_MIN <= threshold <= _THRESHOLD_MAX: + raise ValueError( + f"threshold must be between {_THRESHOLD_MIN} and {_THRESHOLD_MAX}, " + f"got {threshold}" + ) + if len(guardrail_text) > _MAX_GUARDRAIL_TEXT_LENGTH: + raise ValueError( + f"guardrail_text exceeds the {_MAX_GUARDRAIL_TEXT_LENGTH}-character " + f"limit (got {len(guardrail_text)})" + ) + positive_examples = list(positive_examples or []) + negative_examples = list(negative_examples or []) + for label, examples in ( + ("positive_examples", positive_examples), + ("negative_examples", negative_examples), + ): + if len(examples) > _MAX_EXAMPLES_PER_LIST: + raise ValueError( + f"{label} allows at most {_MAX_EXAMPLES_PER_LIST} examples " + f"(got {len(examples)})" + ) + if any(len(e) > _MAX_EXAMPLE_LENGTH for e in examples): + raise ValueError( + f"each {label} entry must be at most {_MAX_EXAMPLE_LENGTH} characters" + ) + self.guardrail_text = guardrail_text + self.model = model + self.positive_examples = positive_examples + self.negative_examples = negative_examples + self.threshold = threshold + + def get_built_in_guardrail( + self, + name: str, + description: str | None, + enabled_for_evals: bool, + ) -> BuiltInValidatorGuardrail: + """Build an LLM-as-judge :class:`BuiltInValidatorGuardrail`. + + Args: + name: Name for the guardrail. + description: Optional description. + enabled_for_evals: Whether active in evaluation scenarios. + + Returns: + Configured :class:`BuiltInValidatorGuardrail` for llm_as_judge. + """ + validator_parameters: list[Any] = [ + TextParameterValue( + parameter_type="text", + id="guardrailText", + value=self.guardrail_text, + ), + EnumParameterValue( + parameter_type="enum", + id="model", + value=self.model, + ), + NumberParameterValue( + parameter_type="number", + id="threshold", + value=self.threshold, + ), + ] + if self.positive_examples: + validator_parameters.append( + TextListParameterValue( + parameter_type="text-list", + id="positiveExamples", + value=self.positive_examples, + ) + ) + if self.negative_examples: + validator_parameters.append( + TextListParameterValue( + parameter_type="text-list", + id="negativeExamples", + value=self.negative_examples, + ) + ) + + return BuiltInValidatorGuardrail( + id=str(uuid4()), + name=name, + description=description or "LLM-as-judge evaluation", + enabled_for_evals=enabled_for_evals, + guardrail_type="builtInValidator", + validator_type="llm_as_judge", + validator_parameters=validator_parameters, + ) diff --git a/packages/uipath-platform/tests/services/test_guardrails_decorators.py b/packages/uipath-platform/tests/services/test_guardrails_decorators.py index e578cba84..29674ca09 100644 --- a/packages/uipath-platform/tests/services/test_guardrails_decorators.py +++ b/packages/uipath-platform/tests/services/test_guardrails_decorators.py @@ -1169,3 +1169,70 @@ def submit_joke(joke: str) -> str: # Short joke passes through assert submit_joke(joke="short") == "short" + + +# --------------------------------------------------------------------------- +# LLMAsJudgeValidator via @guardrail +# --------------------------------------------------------------------------- + + +class TestLLMAsJudgeDecorator: + """@guardrail(validator=LLMAsJudgeValidator(...)) behavior on a mocked verdict.""" + + def test_block_action_raises_on_failed_verdict(self): + mock_validator = MagicMock() + mock_validator.supported_stages = [] + mock_validator.validate_stage = MagicMock() + mock_validator.run.return_value = _FAILED + + @guardrail( + validator=mock_validator, + action=BlockAction(title="Blocked", detail="off-topic"), + stage=GuardrailExecutionStage.POST, + ) + def joke(topic: str) -> str: + return f"joke about {topic}" + + with pytest.raises(GuardrailBlockException, match="Blocked"): + joke("cats") + + def test_log_action_continues_on_failed_verdict(self, caplog): + mock_validator = MagicMock() + mock_validator.supported_stages = [] + mock_validator.validate_stage = MagicMock() + mock_validator.run.return_value = _FAILED + + @guardrail( + validator=mock_validator, + action=LogAction(), + stage=GuardrailExecutionStage.PRE, + name="Judge", + ) + def joke(topic: str) -> str: + return f"joke about {topic}" + + with caplog.at_level(logging.WARNING): + assert joke("cats") == "joke about cats" # log does not block + assert any("Judge" in r.message for r in caplog.records) + + def test_real_validator_block_end_to_end_mocked_backend(self): + """Real LLMAsJudgeValidator.run() path with the UiPath API mocked.""" + from uipath.platform.guardrails.decorators import LLMAsJudgeValidator + + mock_uipath = MagicMock() + mock_uipath.guardrails.evaluate_guardrail.return_value = _FAILED + + @guardrail( + validator=LLMAsJudgeValidator( + guardrail_text="Must be on-topic.", model="gpt-4o-2024-08-06" + ), + action=BlockAction(title="Off-topic", detail="blocked"), + stage=GuardrailExecutionStage.POST, + ) + def joke(topic: str) -> str: + return f"joke about {topic}" + + with patch("uipath.platform.UiPath", return_value=mock_uipath): + with pytest.raises(GuardrailBlockException): + joke("cats") + mock_uipath.guardrails.evaluate_guardrail.assert_called_once() diff --git a/packages/uipath-platform/tests/services/test_llm_as_judge_validator.py b/packages/uipath-platform/tests/services/test_llm_as_judge_validator.py new file mode 100644 index 000000000..aba269511 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_llm_as_judge_validator.py @@ -0,0 +1,142 @@ +"""Tests for LLMAsJudgeValidator — the decorator-path llm_as_judge validator. + +Verifies guardrail construction (validator_type + parameters), the +examples-only-when-present behavior, selector-is-None convention, stage support, +and input validation. Pure construction — no network. +""" + +from __future__ import annotations + +import pytest + +from uipath.platform.guardrails.decorators import ( + GuardrailExecutionStage, + LLMAsJudgeValidator, +) + +_RULE = "The answer must be genuinely funny, clean, and on-topic." + + +class TestLLMAsJudgeValidator: + """Tests for LLMAsJudgeValidator.""" + + def test_builds_guardrail_type(self): + guardrail = LLMAsJudgeValidator( + guardrail_text=_RULE, model="gpt-4o-2024-08-06" + ).get_built_in_guardrail(name="Judge", description=None, enabled_for_evals=True) + assert guardrail.guardrail_type == "builtInValidator" + assert guardrail.validator_type == "llm_as_judge" + + def test_required_parameters(self): + guardrail = LLMAsJudgeValidator( + guardrail_text=_RULE, model="gpt-4o-2024-08-06", threshold=2.0 + ).get_built_in_guardrail("Judge", None, True) + params = {p.id: p for p in guardrail.validator_parameters} + assert params["guardrailText"].parameter_type == "text" + assert params["guardrailText"].value == _RULE + assert params["model"].parameter_type == "enum" + assert params["model"].value == "gpt-4o-2024-08-06" + assert params["threshold"].parameter_type == "number" + assert params["threshold"].value == 2.0 + + def test_examples_only_when_present(self): + ids = { + p.id + for p in LLMAsJudgeValidator(guardrail_text=_RULE, model="m") + .get_built_in_guardrail("Judge", None, True) + .validator_parameters + } + assert "positiveExamples" not in ids + assert "negativeExamples" not in ids + + params = { + p.id: p + for p in LLMAsJudgeValidator( + guardrail_text=_RULE, + model="m", + positive_examples=["a clean pun"], + negative_examples=["not a joke"], + ) + .get_built_in_guardrail("Judge", None, True) + .validator_parameters + } + assert params["positiveExamples"].parameter_type == "text-list" + assert params["positiveExamples"].value == ["a clean pun"] + assert params["negativeExamples"].value == ["not a joke"] + + def test_selector_is_none(self): + # Decorator-path convention (matches PIIValidator / PromptInjectionValidator): + # scope comes from the decorated target, not the validator. + guardrail = LLMAsJudgeValidator( + guardrail_text=_RULE, model="m" + ).get_built_in_guardrail("Judge", None, True) + assert guardrail.selector is None + + def test_default_description(self): + guardrail = LLMAsJudgeValidator( + guardrail_text=_RULE, model="m" + ).get_built_in_guardrail("Judge", None, True) + assert guardrail.description == "LLM-as-judge evaluation" + + def test_all_stages_supported(self): + validator = LLMAsJudgeValidator(guardrail_text=_RULE, model="m") + assert validator.supported_stages == [] + # No stage restriction: both PRE and POST validate without raising. + validator.validate_stage(GuardrailExecutionStage.PRE) + validator.validate_stage(GuardrailExecutionStage.POST) + + def test_empty_guardrail_text_raises(self): + with pytest.raises(ValueError, match="guardrail_text"): + LLMAsJudgeValidator(guardrail_text=" ", model="m") + + def test_empty_model_raises(self): + with pytest.raises(ValueError, match="model"): + LLMAsJudgeValidator(guardrail_text=_RULE, model="") + + def test_threshold_out_of_range_raises(self): + with pytest.raises(ValueError, match="threshold"): + LLMAsJudgeValidator(guardrail_text=_RULE, model="m", threshold=7.0) + + def test_guardrail_text_over_limit_raises(self): + with pytest.raises(ValueError, match="guardrail_text exceeds"): + LLMAsJudgeValidator(guardrail_text="x" * 4001, model="m") + + def test_guardrail_text_at_limit_ok(self): + LLMAsJudgeValidator(guardrail_text="x" * 4000, model="m") + + def test_too_many_positive_examples_raises(self): + with pytest.raises(ValueError, match="positive_examples allows at most 2"): + LLMAsJudgeValidator( + guardrail_text=_RULE, model="m", positive_examples=["a", "b", "c"] + ) + + def test_too_many_negative_examples_raises(self): + with pytest.raises(ValueError, match="negative_examples allows at most 2"): + LLMAsJudgeValidator( + guardrail_text=_RULE, model="m", negative_examples=["a", "b", "c"] + ) + + def test_two_examples_each_ok(self): + LLMAsJudgeValidator( + guardrail_text=_RULE, + model="m", + positive_examples=["a", "b"], + negative_examples=["c", "d"], + ) + + def test_positive_example_over_length_raises(self): + with pytest.raises(ValueError, match="positive_examples entry"): + LLMAsJudgeValidator( + guardrail_text=_RULE, model="m", positive_examples=["x" * 1001] + ) + + def test_negative_example_over_length_raises(self): + with pytest.raises(ValueError, match="negative_examples entry"): + LLMAsJudgeValidator( + guardrail_text=_RULE, model="m", negative_examples=["x" * 1001] + ) + + def test_example_at_length_limit_ok(self): + LLMAsJudgeValidator( + guardrail_text=_RULE, model="m", positive_examples=["x" * 1000] + ) diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index f5253f90c..2144530f3 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.4" +version = "0.2.5" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 19e974d12..070142638 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2741,7 +2741,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.4" +version = "0.2.5" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" },