|
| 1 | +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
| 2 | +# SPDX-License-Identifier: Apache-2.0 |
| 3 | +# |
| 4 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | +# you may not use this file except in compliance with the License. |
| 6 | +# You may obtain a copy of the License at |
| 7 | +# |
| 8 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | +# |
| 10 | +# Unless required by applicable law or agreed to in writing, software |
| 11 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | +# See the License for the specific language governing permissions and |
| 14 | +# limitations under the License. |
| 15 | + |
| 16 | +"""The engine-neutral outcome of a single rail check. |
| 17 | +
|
| 18 | +``RailOutcome`` is the canonical, backend-agnostic verdict a rail produces. It |
| 19 | +carries only the DECISION and neutral evidence, never how a consequence is |
| 20 | +rendered: which exception to raise, which bot intent or refusal message to |
| 21 | +emit, and how to localize it are presentation concerns that each engine (the |
| 22 | +Colang flow, IORails) decides for itself from this outcome plus its config. |
| 23 | +
|
| 24 | +Fields: |
| 25 | +- ``decision``: ALLOW / BLOCK / TRANSFORM. |
| 26 | +- ``reason``: optional neutral, human-readable explanation. |
| 27 | +- ``metadata``: neutral evidence the decision is based on (policy violations, |
| 28 | + categories, scores). Never load-bearing for the decision itself. |
| 29 | +- ``transforms``: for TRANSFORM, which conversation variables are rewritten |
| 30 | + and to what. Transform outcomes are non-streaming; streaming output bypasses |
| 31 | + observe them as non-blocking and do not apply rewrites. |
| 32 | +
|
| 33 | +Deliberately NOT here (they are rendering, not decision): exception type, |
| 34 | +refusal intent/message, language, and Colang event/context-update channels. |
| 35 | +A BLOCK always means "stop"; there is no soft-block flag. |
| 36 | +""" |
| 37 | + |
| 38 | +from collections.abc import Sequence |
| 39 | +from dataclasses import dataclass, field |
| 40 | +from enum import Enum |
| 41 | +from typing import Any |
| 42 | + |
| 43 | + |
| 44 | +class RailDecision(Enum): |
| 45 | + """The three mutually exclusive things a rail can decide.""" |
| 46 | + |
| 47 | + ALLOW = "allow" |
| 48 | + BLOCK = "block" |
| 49 | + TRANSFORM = "transform" |
| 50 | + |
| 51 | + |
| 52 | +class TransformTarget(str, Enum): |
| 53 | + """The conversation variable a transform rewrites.""" |
| 54 | + |
| 55 | + USER_MESSAGE = "user_message" |
| 56 | + BOT_MESSAGE = "bot_message" |
| 57 | + RELEVANT_CHUNKS = "relevant_chunks" |
| 58 | + |
| 59 | + |
| 60 | +@dataclass(frozen=True, slots=True) |
| 61 | +class TransformSpec: |
| 62 | + """A rewrite of a single conversation variable.""" |
| 63 | + |
| 64 | + target: TransformTarget |
| 65 | + text: str |
| 66 | + |
| 67 | + |
| 68 | +@dataclass(frozen=True, slots=True) |
| 69 | +class RailOutcome: |
| 70 | + """The engine-neutral verdict of one rail check. |
| 71 | +
|
| 72 | + ``transforms`` is non-empty if and only if ``decision`` is TRANSFORM. Each |
| 73 | + engine renders the consequence of a BLOCK its own way; this object does not |
| 74 | + encode it. |
| 75 | + """ |
| 76 | + |
| 77 | + decision: RailDecision |
| 78 | + reason: str | None = None |
| 79 | + metadata: dict[str, Any] = field(default_factory=dict) |
| 80 | + transforms: tuple[TransformSpec, ...] = () |
| 81 | + |
| 82 | + def __post_init__(self) -> None: |
| 83 | + if bool(self.transforms) != (self.decision is RailDecision.TRANSFORM): |
| 84 | + raise ValueError("transforms must be non-empty if and only if decision is TRANSFORM") |
| 85 | + |
| 86 | + @property |
| 87 | + def is_blocked(self) -> bool: |
| 88 | + """The single field both engines read to gate; rendering is theirs.""" |
| 89 | + return self.decision is RailDecision.BLOCK |
| 90 | + |
| 91 | + @property |
| 92 | + def is_transform(self) -> bool: |
| 93 | + return self.decision is RailDecision.TRANSFORM |
| 94 | + |
| 95 | + @property |
| 96 | + def transform_text(self) -> dict[str, str]: |
| 97 | + return {spec.target.value: spec.text for spec in self.transforms} |
| 98 | + |
| 99 | + @classmethod |
| 100 | + def allow(cls, *, reason: str | None = None, **metadata: Any) -> "RailOutcome": |
| 101 | + return cls(decision=RailDecision.ALLOW, reason=reason, metadata=dict(metadata)) |
| 102 | + |
| 103 | + @classmethod |
| 104 | + def block(cls, *, reason: str | None = None, **metadata: Any) -> "RailOutcome": |
| 105 | + return cls(decision=RailDecision.BLOCK, reason=reason, metadata=dict(metadata)) |
| 106 | + |
| 107 | + @classmethod |
| 108 | + def transform( |
| 109 | + cls, |
| 110 | + rewrites: Sequence[tuple[TransformTarget, str]], |
| 111 | + *, |
| 112 | + reason: str | None = None, |
| 113 | + **metadata: Any, |
| 114 | + ) -> "RailOutcome": |
| 115 | + return cls( |
| 116 | + decision=RailDecision.TRANSFORM, |
| 117 | + reason=reason, |
| 118 | + transforms=tuple(TransformSpec(target=target, text=text) for target, text in rewrites), |
| 119 | + metadata=dict(metadata), |
| 120 | + ) |
| 121 | + |
| 122 | + |
| 123 | +def require_rail_outcome(result: Any) -> RailOutcome: |
| 124 | + if isinstance(result, tuple): |
| 125 | + result = result[0] |
| 126 | + if not isinstance(result, RailOutcome): |
| 127 | + raise TypeError(f"Output rail action must return RailOutcome, got {type(result).__name__}") |
| 128 | + return result |
0 commit comments