c56bef871b
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
Docker image release / Build base image (push) Has been cancelled
Sync docs with Docusaurus / sync (push) Has been cancelled
Tests / Check if changed (push) Has been cancelled
Tests / format (push) Has been cancelled
Tests / check-imports (push) Has been cancelled
Tests / Unit / macos-latest (push) Has been cancelled
Tests / Unit / ubuntu-latest (push) Has been cancelled
Tests / Unit / windows-latest (push) Has been cancelled
Tests / mypy (push) Has been cancelled
Tests / Integration / ubuntu-latest (push) Has been cancelled
Tests / Integration / macos-latest (push) Has been cancelled
Tests / Integration / windows-latest (push) Has been cancelled
Tests / notify-slack-on-failure (push) Has been cancelled
Tests / Mark tests as completed (push) Has been cancelled
180 lines
6.6 KiB
Python
180 lines
6.6 KiB
Python
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
|
#
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
import ast
|
|
import contextlib
|
|
from collections.abc import Callable
|
|
from typing import Any, TypeAlias
|
|
|
|
import jinja2.runtime
|
|
from jinja2 import TemplateSyntaxError
|
|
from jinja2.nativetypes import NativeEnvironment
|
|
from jinja2.sandbox import SandboxedEnvironment
|
|
|
|
from haystack import component, default_from_dict, default_to_dict, logging
|
|
from haystack.utils import deserialize_callable, deserialize_type, serialize_callable, serialize_type
|
|
from haystack.utils.jinja2_extensions import _extract_template_variables_and_assignments
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class OutputAdaptationException(Exception):
|
|
"""Exception raised when there is an error during output adaptation."""
|
|
|
|
|
|
@component
|
|
class OutputAdapter:
|
|
"""
|
|
Adapts output of a Component using Jinja templates.
|
|
|
|
Usage example:
|
|
```python
|
|
from haystack import Document
|
|
from haystack.components.converters import OutputAdapter
|
|
|
|
adapter = OutputAdapter(template="{{ documents[0].content }}", output_type=str)
|
|
documents = [Document(content="Test content")]
|
|
result = adapter.run(documents=documents)
|
|
|
|
assert result["output"] == "Test content"
|
|
```
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
template: str,
|
|
output_type: TypeAlias,
|
|
custom_filters: dict[str, Callable] | None = None,
|
|
unsafe: bool = False,
|
|
) -> None:
|
|
"""
|
|
Create an OutputAdapter component.
|
|
|
|
:param template:
|
|
A Jinja template that defines how to adapt the input data.
|
|
The variables in the template define the input of this instance.
|
|
e.g.
|
|
With this template:
|
|
```
|
|
{{ documents[0].content }}
|
|
```
|
|
The Component input will be `documents`.
|
|
:param output_type:
|
|
The type of output this instance will return.
|
|
:param custom_filters:
|
|
A dictionary of custom Jinja filters used in the template.
|
|
:param unsafe:
|
|
Enable execution of arbitrary code in the Jinja template.
|
|
This should only be used if you trust the source of the template as it can be lead to remote code execution.
|
|
"""
|
|
self.custom_filters = {**(custom_filters or {})}
|
|
input_types: set[str] = set()
|
|
|
|
self._unsafe = unsafe
|
|
|
|
if self._unsafe:
|
|
msg = (
|
|
"Unsafe mode is enabled. This allows execution of arbitrary code in the Jinja template. "
|
|
"Use this only if you trust the source of the template."
|
|
)
|
|
logger.warning(msg)
|
|
self._env = (
|
|
NativeEnvironment() if self._unsafe else SandboxedEnvironment(undefined=jinja2.runtime.StrictUndefined)
|
|
)
|
|
|
|
try:
|
|
self._env.parse(template) # Validate template syntax
|
|
self.template = template
|
|
except TemplateSyntaxError as e:
|
|
raise ValueError(f"Invalid Jinja template '{template}': {e}") from e
|
|
|
|
for name, filter_func in self.custom_filters.items():
|
|
self._env.filters[name] = filter_func
|
|
|
|
# b) extract variables in the template
|
|
assigned_variables, template_variables = _extract_template_variables_and_assignments(
|
|
env=self._env, template=self.template
|
|
)
|
|
route_input_names = template_variables - assigned_variables
|
|
input_types.update(route_input_names)
|
|
|
|
# the env is not needed, discarded automatically
|
|
component.set_input_types(self, **dict.fromkeys(input_types, Any))
|
|
component.set_output_types(self, output=output_type)
|
|
self.output_type = output_type
|
|
|
|
def run(self, **kwargs: Any) -> dict[str, Any]:
|
|
"""
|
|
Renders the Jinja template with the provided inputs.
|
|
|
|
:param kwargs:
|
|
Must contain all variables used in the `template` string.
|
|
:returns:
|
|
A dictionary with the following keys:
|
|
- `output`: Rendered Jinja template.
|
|
|
|
:raises OutputAdaptationException: If template rendering fails.
|
|
"""
|
|
# check if kwargs are empty
|
|
if not kwargs:
|
|
raise ValueError("No input data provided for output adaptation")
|
|
for name, filter_func in self.custom_filters.items():
|
|
self._env.filters[name] = filter_func
|
|
adapted_outputs = {}
|
|
try:
|
|
adapted_output_template = self._env.from_string(self.template)
|
|
output_result = adapted_output_template.render(**kwargs)
|
|
if isinstance(output_result, jinja2.runtime.Undefined):
|
|
raise OutputAdaptationException(f"Undefined variable in the template {self.template}; kwargs: {kwargs}") # noqa: TRY301
|
|
|
|
# We suppress the exception in case the output is already a string, otherwise
|
|
# we try to evaluate it and would fail.
|
|
# This must be done cause the output could be different literal structures.
|
|
# This doesn't support any user types.
|
|
with contextlib.suppress(Exception):
|
|
if not self._unsafe:
|
|
output_result = ast.literal_eval(output_result)
|
|
|
|
adapted_outputs["output"] = output_result
|
|
except Exception as e:
|
|
raise OutputAdaptationException(f"Error adapting {self.template} with {kwargs}: {e}") from e
|
|
return adapted_outputs
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
"""
|
|
Serializes the component to a dictionary.
|
|
|
|
:returns:
|
|
Dictionary with serialized data.
|
|
"""
|
|
se_filters = {name: serialize_callable(filter_func) for name, filter_func in self.custom_filters.items()}
|
|
return default_to_dict(
|
|
self,
|
|
template=self.template,
|
|
output_type=serialize_type(self.output_type),
|
|
custom_filters=se_filters,
|
|
unsafe=self._unsafe,
|
|
)
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict[str, Any]) -> "OutputAdapter":
|
|
"""
|
|
Deserializes the component from a dictionary.
|
|
|
|
:param data:
|
|
The dictionary to deserialize from.
|
|
:returns:
|
|
The deserialized component.
|
|
"""
|
|
init_params = data.get("init_parameters", {})
|
|
init_params["output_type"] = deserialize_type(init_params["output_type"])
|
|
|
|
custom_filters = init_params.get("custom_filters", {})
|
|
if custom_filters:
|
|
init_params["custom_filters"] = {
|
|
name: deserialize_callable(filter_func) if filter_func else None
|
|
for name, filter_func in custom_filters.items()
|
|
}
|
|
return default_from_dict(cls, data)
|