from __future__ import annotations from typing import Any, Dict, List, Optional, Union, Iterable from builtins import id as object_id from pydantic import Field, field_serializer, field_validator, computed_field from rich.panel import Panel from rich.pretty import pretty_repr from rich.tree import Tree from typing_extensions import deprecated from guardrails_ai.types import Outcome, ValidationResult from guardrails.actions.filter import Filter from guardrails.actions.refrain import Refrain from guardrails.actions.reask import merge_reask_output from guardrails.classes.generic.stack import Stack from guardrails.classes.history.call_inputs import CallInputs from guardrails.classes.history.iteration import Iteration from guardrails.classes.generic.arbitrary_model import ArbitraryModel from guardrails.constants import error_status, fail_status, not_run_status, pass_status from guardrails.prompt.messages import Messages from guardrails.prompt import Prompt, Instructions from guardrails.classes.validation.validator_logs import ValidatorLogs from guardrails.actions.reask import ( ReAsk, gather_reasks, sub_reasks_with_fixed_values, ) from guardrails.schema.parser import get_value_from_path # We can't inherit from Iteration because python # won't let you override a class attribute with a managed attribute class Call(ArbitraryModel): """A Call represents a single execution of a Guard. One Call is created each time the user invokes the `Guard.__call__`, `Guard.parse`, or `Guard.validate` method. Attributes: iterations (Stack[Iteration]): A stack of iterations for the initial validation round and one for each reask that occurs during a Call. inputs (CallInputs): The inputs as passed in to `Guard.__call__`, `Guard.parse`, or `Guard.validate` exception (Optional[Exception]): The exception that interrupted the Guard execution. """ _id: str | None = None iterations: Stack[Iteration] = Field( description="A stack of iterations for each" "step/reask that occurred during this call.", default_factory=Stack, ) inputs: CallInputs = Field( description="The inputs as passed in to Guard.__call__ or Guard.parse", default_factory=CallInputs, ) exception: Optional[Exception] = Field( description="The exception that interrupted the run.", default=None, ) @computed_field @property def id(self) -> str: """The unique identifier for this Call. Can be used as an identifier for a specific execution of a Guard. """ if not self._id: self._id = str(object_id(self)) return self._id @field_serializer("iterations") def serialize_iterations( self, iterations: Stack[Iteration] | None ) -> list[dict[str, Any]] | None: if iterations is not None: return [i.model_dump(exclude_none=True, by_alias=True) for i in iterations] return [] @field_validator("iterations", mode="before") @classmethod def deserialize_iterations(cls, iterations: Any) -> Stack[Iteration] | None: if iterations is not None and isinstance(iterations, Iterable): _iterations = [] for i in iterations: if isinstance(i, Iteration): _iterations.append(i) else: iteration = Iteration.model_validate(i) iteration._id = i.get("id") or iteration._id _iterations.append(iteration) return Stack(*_iterations) return Stack() @field_serializer("exception") def serialize_exception(self, exception: Exception | None) -> str | None: if exception: return str(exception) return None @field_validator("exception", mode="before") @classmethod def deserialize_exception(cls, exception: Any) -> Exception | None: if isinstance(exception, Exception): return exception if exception and isinstance(exception, str): return Exception(exception) return None @property def prompt_params(self) -> Optional[Dict]: """The prompt parameters as provided by the user when initializing or calling the Guard.""" return self.inputs.prompt_params @property def messages(self) -> Optional[Union[Messages, list[dict[str, str]]]]: """The messages as provided by the user when initializing or calling the Guard.""" return self.inputs.messages @property def compiled_messages(self) -> Optional[list[dict[str, str]]]: """The initial compiled messages that were passed to the LLM on the first call.""" if self.iterations.empty(): return None initial_inputs = self.iterations.first.inputs # type: ignore messages = initial_inputs.messages prompt_params = initial_inputs.prompt_params or {} compiled_messages = [] if messages is None: return None for message in messages: content = message["content"].format(**prompt_params) if isinstance(content, (Prompt, Instructions)): content = content._source compiled_messages.append( { "role": message["role"], "content": content, } ) return compiled_messages @property def reask_messages(self) -> Stack[Messages]: """The compiled messages used during reasks. Does not include the initial messages. """ if self.iterations.length > 0: reasks = self.iterations.copy() initial_messages = reasks.first reasks.remove(initial_messages) # type: ignore initial_inputs = self.iterations.first.inputs # type: ignore prompt_params = initial_inputs.prompt_params or {} compiled_reasks = [] for reask in reasks: messages = reask.inputs.messages if messages is None: compiled_reasks.append(None) else: compiled_messages = [] for message in messages: content = message["content"].format(**prompt_params) if isinstance(content, (Prompt, Instructions)): content = content._source compiled_messages.append( { "role": message["role"], "content": content, } ) compiled_reasks.append(compiled_messages) return Stack(*compiled_reasks) return Stack() @property def logs(self) -> Stack[str]: """Returns all logs from all iterations as a stack.""" all_logs = [] for i in self.iterations: all_logs.extend(i.logs) return Stack(*all_logs) @property def tokens_consumed(self) -> Optional[int]: """Returns the total number of tokens consumed during all iterations with this call.""" iteration_tokens = [ i.tokens_consumed for i in self.iterations if i.tokens_consumed is not None ] if len(iteration_tokens) > 0: return sum(iteration_tokens) return None @property def prompt_tokens_consumed(self) -> Optional[int]: """Returns the total number of prompt tokens consumed during all iterations with this call.""" iteration_tokens = [ i.prompt_tokens_consumed for i in self.iterations if i.prompt_tokens_consumed is not None ] if len(iteration_tokens) > 0: return sum(iteration_tokens) return None @property def completion_tokens_consumed(self) -> Optional[int]: """Returns the total number of completion tokens consumed during all iterations with this call.""" iteration_tokens = [ i.completion_tokens_consumed for i in self.iterations if i.completion_tokens_consumed is not None ] if len(iteration_tokens) > 0: return sum(iteration_tokens) return None @property def raw_outputs(self) -> Stack[str]: """The exact outputs from all LLM calls.""" return Stack( *[ i.outputs.llm_response_info.output if i.outputs.llm_response_info is not None else None for i in self.iterations ] ) @property def parsed_outputs(self) -> Stack[Union[str, List, Dict]]: """The outputs from the LLM after undergoing parsing but before validation.""" return Stack(*[i.outputs.parsed_output for i in self.iterations]) @property def validation_response(self) -> Optional[Union[str, List, Dict, ReAsk]]: """The aggregated responses from the validation process across all iterations within the current call. This value could contain ReAsks. """ number_of_iterations = self.iterations.length if number_of_iterations == 0: return None # Don't try to merge if # 1. We plan to perform full schema reasks # 2. There's nothing to merge # 3. The output is a top level ReAsk (i.e. SkeletonReAsk or NonParseableReask) # 4. The output is a string if ( self.inputs.full_schema_reask or number_of_iterations < 2 or isinstance( self.iterations.last.validation_response, # type: ignore ReAsk, # type: ignore ) or isinstance(self.iterations.last.validation_response, str) # type: ignore ): return self.iterations.last.validation_response # type: ignore current_index = 1 # We've already established that there are iterations, # hence the type ignores merged_validation_responses = ( self.iterations.first.validation_response # type: ignore ) while current_index < number_of_iterations: current_validation_output = self.iterations.at( current_index ).validation_response # type: ignore merged_validation_responses = merge_reask_output( merged_validation_responses, current_validation_output ) current_index = current_index + 1 return merged_validation_responses @property def fixed_output(self) -> Optional[Union[str, List, Dict]]: """The cumulative output from the validation process across all current iterations with any automatic fixes applied. Could still contain ReAsks if a fix was not available. """ return sub_reasks_with_fixed_values(self.validation_response) @property def guarded_output(self) -> Optional[Union[str, List, Dict]]: """The complete validated output after all stages of validation are completed. This property contains the aggregate validated output after all validation stages have been completed. Some values in the validated output may be "fixed" values that were corrected during validation. This will only have a value if the Guard is in a passing state OR if the action is no-op. """ if self.status == pass_status: return self.fixed_output last_iteration = self.iterations.last if ( not self.status == pass_status and last_iteration and last_iteration.failed_validations ): # check that all failed validations are noop or none all_noop = True for failed_validation in last_iteration.failed_validations: if ( failed_validation.value_after_validation is not failed_validation.value_before_validation ): all_noop = False break if all_noop: return last_iteration.guarded_output @property def reasks(self) -> Stack[ReAsk]: """Reasks generated during validation that could not be automatically fixed. These would be incorporated into the prompt for the next LLM call if additional reasks were granted. """ reasks, _ = gather_reasks(self.fixed_output) return Stack(*reasks) @property def validator_logs(self) -> Stack[ValidatorLogs]: """The results of each individual validation performed on the LLM responses during all iterations.""" all_validator_logs = Stack() for i in self.iterations: all_validator_logs.extend(i.validator_logs) return all_validator_logs @property def error(self) -> Optional[str]: """The error message from any exception that raised and interrupted the run.""" if self.exception: return str(self.exception) elif self.iterations.empty(): return None return self.iterations.last.error # type: ignore @property def failed_validations(self) -> Stack[ValidatorLogs]: """The validator logs for any validations that failed during the entirety of the run.""" return Stack( *[ log for log in self.validator_logs if log.validation_result is not None and isinstance(log.validation_result, ValidationResult) and log.validation_result.outcome == Outcome.FAIL ] ) def _has_unresolved_failures(self) -> bool: # Check for unresolved ReAsks if len(self.reasks) > 0: return True # Check for scenario where no specified on-fail's produced an unfixed ReAsk, # but valdiation still failed (i.e. Refrain or NoOp). output = self.fixed_output for failure in self.failed_validations: value = get_value_from_path(output, failure.property_path) if ( # NOTE: this means on_fail="fix" was applied # to a Validator without a programmatic fix. (value is None and failure.value_before_validation is not None) or value == failure.value_before_validation or isinstance(failure.value_after_validation, Refrain) or isinstance(failure.value_after_validation, Filter) ): return True # No ReAsks and no unresolved failed validations return False @property def status(self) -> str: """Returns the cumulative status of the run based on the validity of the final merged output.""" if self.iterations.empty(): return not_run_status elif self.error: return error_status elif self._has_unresolved_failures(): return fail_status return pass_status @property def tree(self) -> Tree: """Returns the tree.""" tree = Tree("Logs") for i, iteration in enumerate(self.iterations): tree.add(Panel(iteration.rich_group, title=f"Step {i}")) # Replace the last Validated Output panel if we applied fixes if self.failed_validations.length > 0 and self.status == pass_status: previous_panels = tree.children[ # type: ignore -1 ].label.renderable._renderables[ # type: ignore :-1 ] validated_outcome_panel = Panel( pretty_repr(self.guarded_output), title="Validated Output", style="on #F0FFF0", ) tree.children[-1].label.renderable._renderables = previous_panels + ( # type: ignore validated_outcome_panel, ) return tree def __str__(self) -> str: return pretty_repr(self) @deprecated("Use Call.model_dump() instead.") def to_interface(self) -> dict[str, Any]: return self.model_dump(exclude_none=True, by_alias=True) @deprecated("Use Call.model_dump() instead.") def to_dict(self) -> dict[str, Any]: return self.model_dump(exclude_none=True, by_alias=True) @classmethod @deprecated("Use Call.model_validate() instead.") def from_interface(cls, i_call: Any) -> "Call": return cls.model_validate(i_call) # TODO: Necessary to GET /guards/{guard_name}/history/{call_id} @classmethod @deprecated("Use Call.model_validate() instead.") def from_dict(cls, obj: Any) -> "Call": return cls.model_validate(obj)