deepset-ai--haystack
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
433 行
18 KiB
Python
433 行
18 KiB
Python
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
|
#
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
import base64
|
|
import colorsys
|
|
import json
|
|
import random
|
|
import zlib
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import networkx
|
|
|
|
from haystack import logging
|
|
from haystack.core.errors import PipelineDrawingError
|
|
from haystack.core.pipeline.descriptions import find_pipeline_inputs, find_pipeline_outputs
|
|
from haystack.core.type_utils import _type_name
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def generate_color_variations(n: int, base_color: str | None = "#3498DB", variation_range: float = 0.4) -> list[str]:
|
|
"""
|
|
Generate n different variations of a base color.
|
|
|
|
:param n: Number of variations to generate
|
|
:param base_color: Hex color code, default is a shade of blue (#3498DB)
|
|
:param variation_range: Range for varying brightness and saturation (0-1)
|
|
|
|
:returns:
|
|
list: List of hex color codes representing variations of the base color
|
|
"""
|
|
# convert hex to RGB
|
|
base_color = base_color.lstrip("#") # type:ignore
|
|
r = int(base_color[0:2], 16) / 255.0
|
|
g = int(base_color[2:4], 16) / 255.0
|
|
b = int(base_color[4:6], 16) / 255.0
|
|
|
|
# convert RGB to HSV (Hue, Saturation, Value)
|
|
h, s, v = colorsys.rgb_to_hsv(r, g, b)
|
|
|
|
variations = []
|
|
for _ in range(n):
|
|
# vary saturation and brightness within the specified range
|
|
new_s = max(0, min(1, s + random.uniform(-variation_range, variation_range)))
|
|
new_v = max(0, min(1, v + random.uniform(-variation_range, variation_range)))
|
|
|
|
# keep hue the same for color consistency
|
|
new_h = h
|
|
|
|
# Convert back to RGB and then to hex
|
|
new_r, new_g, new_b = colorsys.hsv_to_rgb(new_h, new_s, new_v)
|
|
hex_color = f"#{int(new_r * 255):02x}{int(new_g * 255):02x}{int(new_b * 255):02x}"
|
|
|
|
variations.append(hex_color)
|
|
|
|
return variations
|
|
|
|
|
|
def _prepare_for_drawing(graph: networkx.MultiDiGraph) -> networkx.MultiDiGraph:
|
|
"""
|
|
Add some extra nodes to show the inputs and outputs of the pipeline.
|
|
|
|
Also adds labels to edges.
|
|
"""
|
|
# Label the edges
|
|
for inp, outp, key, data in graph.edges(keys=True, data=True):
|
|
data["label"] = (
|
|
f"{data['from_socket'].name} -> {data['to_socket'].name}{' (opt.)' if not data['mandatory'] else ''}"
|
|
)
|
|
graph.add_edge(inp, outp, key=key, **data)
|
|
|
|
# Add inputs fake node
|
|
graph.add_node("input")
|
|
for node, in_sockets in find_pipeline_inputs(graph).items():
|
|
for in_socket in in_sockets:
|
|
if not in_socket.senders and in_socket.is_mandatory:
|
|
# If this socket has no sender it could be a socket that receives input
|
|
# directly when running the Pipeline. We can't know that for sure, in doubt
|
|
# we draw it as receiving input directly.
|
|
graph.add_edge("input", node, label=in_socket.name, conn_type=_type_name(in_socket.type))
|
|
|
|
# Add outputs fake node
|
|
graph.add_node("output")
|
|
for node, out_sockets in find_pipeline_outputs(graph).items():
|
|
for out_socket in out_sockets:
|
|
graph.add_edge(node, "output", label=out_socket.name, conn_type=_type_name(out_socket.type))
|
|
|
|
return graph
|
|
|
|
|
|
ARROWTAIL_MANDATORY = "--"
|
|
ARROWTAIL_OPTIONAL = "-."
|
|
ARROWHEAD_MANDATORY = "-->"
|
|
ARROWHEAD_OPTIONAL = ".->"
|
|
MERMAID_STYLED_TEMPLATE = """
|
|
%%{{ init: {params} }}%%
|
|
|
|
graph TD;
|
|
|
|
{connections}
|
|
|
|
classDef component text-align:center;
|
|
{style_definitions}
|
|
"""
|
|
|
|
|
|
def _validate_mermaid_params(params: dict[str, Any]) -> None:
|
|
"""
|
|
Validates and sets default values for Mermaid parameters.
|
|
|
|
:param params:
|
|
Dictionary of customization parameters to modify the output. Refer to Mermaid documentation for more details.
|
|
Supported keys:
|
|
- format: Output format ('img', 'svg', or 'pdf'). Default: 'img'.
|
|
- type: Image type for /img endpoint ('jpeg', 'png', 'webp'). Default: 'png'.
|
|
- theme: Mermaid theme ('default', 'neutral', 'dark', 'forest'). Default: 'neutral'.
|
|
- bgColor: Background color in hexadecimal (e.g., 'FFFFFF') or named format (e.g., '!white').
|
|
- width: Width of the output image (integer).
|
|
- height: Height of the output image (integer).
|
|
- scale: Scaling factor (1–3). Only applicable if 'width' or 'height' is specified.
|
|
- fit: Whether to fit the diagram size to the page (PDF only, boolean).
|
|
- paper: Paper size for PDFs (e.g., 'a4', 'a3'). Ignored if 'fit' is true.
|
|
- landscape: Landscape orientation for PDFs (boolean). Ignored if 'fit' is true.
|
|
|
|
:raises ValueError:
|
|
If any parameter is invalid or does not match the expected format.
|
|
"""
|
|
valid_img_types = {"jpeg", "png", "webp"}
|
|
valid_themes = {"default", "neutral", "dark", "forest"}
|
|
valid_formats = {"img", "svg", "pdf"}
|
|
|
|
params.setdefault("format", "img")
|
|
params.setdefault("type", "png")
|
|
params.setdefault("theme", "neutral")
|
|
|
|
if params["format"] not in valid_formats:
|
|
raise ValueError(f"Invalid image format: {params['format']}. Valid options are: {valid_formats}.")
|
|
|
|
if params["format"] == "img" and params["type"] not in valid_img_types:
|
|
raise ValueError(f"Invalid image type: {params['type']}. Valid options are: {valid_img_types}.")
|
|
|
|
if params["theme"] not in valid_themes:
|
|
raise ValueError(f"Invalid theme: {params['theme']}. Valid options are: {valid_themes}.")
|
|
|
|
if "width" in params and not isinstance(params["width"], int):
|
|
raise ValueError("Width must be an integer.")
|
|
if "height" in params and not isinstance(params["height"], int):
|
|
raise ValueError("Height must be an integer.")
|
|
|
|
if "scale" in params and not 1 <= params["scale"] <= 3:
|
|
raise ValueError("Scale must be a number between 1 and 3.")
|
|
if "scale" in params and not ("width" in params or "height" in params):
|
|
raise ValueError("Scale is only allowed when width or height is set.")
|
|
|
|
if "bgColor" in params and not isinstance(params["bgColor"], str):
|
|
raise ValueError("Background color must be a string.")
|
|
|
|
# PDF specific parameters
|
|
if params["format"] == "pdf":
|
|
if "fit" in params and not isinstance(params["fit"], bool):
|
|
raise ValueError("Fit must be a boolean.")
|
|
if "paper" in params and not isinstance(params["paper"], str):
|
|
raise ValueError("Paper size must be a string (e.g., 'a4', 'a3').")
|
|
if "landscape" in params and not isinstance(params["landscape"], bool):
|
|
raise ValueError("Landscape must be a boolean.")
|
|
if "fit" in params and ("paper" in params or "landscape" in params):
|
|
logger.warning("`fit` overrides `paper` and `landscape` for PDFs. Ignoring `paper` and `landscape`.")
|
|
|
|
|
|
# Magic-byte signatures used to verify a Mermaid server response matches the requested output format.
|
|
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
|
_JPEG_SIGNATURE = b"\xff\xd8\xff"
|
|
_PDF_SIGNATURE = b"%PDF-"
|
|
_RIFF_SIGNATURE = b"RIFF"
|
|
_WEBP_SIGNATURE = b"WEBP"
|
|
_SVG_PREFIXES = (b"<?xml", b"<svg")
|
|
|
|
|
|
def _validate_image_response(resp: httpx.Response, params: dict[str, Any]) -> None:
|
|
"""
|
|
Validate that the Mermaid server response actually contains the expected image/SVG/PDF data.
|
|
|
|
`Pipeline.draw()` writes the raw response body to disk, so a misconfigured or malicious
|
|
`server_url` could otherwise cause arbitrary content (e.g. an HTML error page or a crafted
|
|
payload) to be written verbatim to the output path. As defense-in-depth we check both the
|
|
`Content-Type` header (which the server controls and could spoof) and the response body's
|
|
magic-byte signature (which is harder to forge while still producing a usable payload).
|
|
|
|
:param resp:
|
|
The HTTP response returned by the Mermaid server.
|
|
:param params:
|
|
Validated Mermaid parameters; used to determine the expected output format.
|
|
:raises PipelineDrawingError:
|
|
If the response is empty or does not match the expected format.
|
|
"""
|
|
content = resp.content
|
|
if not content:
|
|
raise PipelineDrawingError("The Mermaid server returned an empty response; no image will be saved.")
|
|
|
|
output_format = params.get("format", "img")
|
|
img_type = params.get("type", "png")
|
|
|
|
# (human-readable label, expected Content-Type prefix, body signature check)
|
|
content_type_prefixes: tuple[str, ...]
|
|
if output_format == "svg":
|
|
expected_label = "SVG"
|
|
content_type_prefixes = ("image/svg+xml", "text/xml", "application/xml")
|
|
stripped = content.lstrip()[:512].lower()
|
|
body_ok = stripped.startswith(_SVG_PREFIXES) or _SVG_PREFIXES[1] in stripped
|
|
elif output_format == "pdf":
|
|
expected_label = "PDF"
|
|
content_type_prefixes = ("application/pdf",)
|
|
body_ok = content.startswith(_PDF_SIGNATURE)
|
|
elif img_type == "jpeg":
|
|
expected_label = "JPEG image"
|
|
content_type_prefixes = ("image/jpeg",)
|
|
body_ok = content.startswith(_JPEG_SIGNATURE)
|
|
elif img_type == "webp":
|
|
expected_label = "WebP image"
|
|
content_type_prefixes = ("image/webp",)
|
|
body_ok = content[0:4] == _RIFF_SIGNATURE and content[8:12] == _WEBP_SIGNATURE
|
|
else: # png (default)
|
|
expected_label = "PNG image"
|
|
content_type_prefixes = ("image/png",)
|
|
body_ok = content.startswith(_PNG_SIGNATURE)
|
|
|
|
# The Content-Type header is server-controlled, so a mismatch is only a warning: the
|
|
# authoritative check is the body signature below.
|
|
content_type = resp.headers.get("content-type", "").split(";")[0].strip().lower()
|
|
if content_type and not content_type.startswith(content_type_prefixes):
|
|
logger.warning(
|
|
"The Mermaid server returned an unexpected Content-Type '{content_type}' (expected {expected}).",
|
|
content_type=content_type,
|
|
expected=expected_label,
|
|
)
|
|
|
|
if not body_ok:
|
|
raise PipelineDrawingError(
|
|
f"The Mermaid server response does not look like a valid {expected_label}. "
|
|
f"This can happen if 'server_url' points to a server that is not a Mermaid renderer. "
|
|
f"To avoid writing untrusted content to disk, no file will be saved."
|
|
)
|
|
|
|
|
|
def _to_mermaid_image(
|
|
graph: networkx.MultiDiGraph,
|
|
server_url: str = "https://mermaid.ink",
|
|
params: dict | None = None,
|
|
timeout: int = 30,
|
|
super_component_mapping: dict[str, str] | None = None,
|
|
) -> bytes:
|
|
"""
|
|
Renders a pipeline using a Mermaid server.
|
|
|
|
:param graph:
|
|
The graph to render as a Mermaid pipeline.
|
|
:param server_url:
|
|
Base URL of the Mermaid server (default: 'https://mermaid.ink').
|
|
:param params:
|
|
Dictionary of customization parameters. See `validate_mermaid_params` for valid keys.
|
|
:param timeout:
|
|
Timeout in seconds for the request to the Mermaid server.
|
|
:returns:
|
|
The image, SVG, or PDF data returned by the Mermaid server as bytes.
|
|
:raises ValueError:
|
|
If any parameter is invalid or does not match the expected format.
|
|
:raises PipelineDrawingError:
|
|
If there is an issue connecting to the Mermaid server or the server returns an error.
|
|
"""
|
|
|
|
if params is None:
|
|
params = {}
|
|
|
|
_validate_mermaid_params(params)
|
|
|
|
theme = params.get("theme")
|
|
init_params = json.dumps({"theme": theme})
|
|
|
|
# Copy the graph to avoid modifying the original
|
|
graph_styled = _to_mermaid_text(graph.copy(), init_params, super_component_mapping)
|
|
json_string = json.dumps({"code": graph_styled})
|
|
|
|
# Compress the JSON string with zlib (RFC 1950)
|
|
compressor = zlib.compressobj(level=9, wbits=15)
|
|
compressed_data = compressor.compress(json_string.encode("utf-8")) + compressor.flush()
|
|
compressed_url_safe_base64 = base64.urlsafe_b64encode(compressed_data).decode("utf-8").strip()
|
|
|
|
# Determine the correct endpoint
|
|
endpoint_format = params.get("format", "img") # Default to /img endpoint
|
|
if endpoint_format not in {"img", "svg", "pdf"}:
|
|
raise ValueError(f"Invalid format: {endpoint_format}. Valid options are 'img', 'svg', or 'pdf'.")
|
|
|
|
# Construct the URL without query parameters
|
|
url = f"{server_url}/{endpoint_format}/pako:{compressed_url_safe_base64}"
|
|
|
|
# Add query parameters adhering to mermaid.ink documentation
|
|
query_params = []
|
|
for key, value in params.items():
|
|
if key not in {"theme", "format"}: # Exclude theme (handled in init_params) and format (endpoint-specific)
|
|
if value is True:
|
|
query_params.append(f"{key}")
|
|
else:
|
|
query_params.append(f"{key}={value}")
|
|
|
|
if query_params:
|
|
url += "?" + "&".join(query_params)
|
|
|
|
logger.debug("Rendering graph at {url}", url=url)
|
|
try:
|
|
resp = httpx.get(url, timeout=timeout)
|
|
if resp.status_code >= 400:
|
|
logger.warning(
|
|
"Failed to draw the pipeline: {server_url} returned status {status_code}",
|
|
server_url=server_url,
|
|
status_code=resp.status_code,
|
|
)
|
|
logger.info("Exact URL requested: {url}", url=url)
|
|
logger.warning("No pipeline diagram will be saved.")
|
|
resp.raise_for_status()
|
|
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Failed to draw the pipeline: could not connect to {server_url} ({error})", server_url=server_url, error=exc
|
|
)
|
|
logger.info("Exact URL requested: {url}", url=url)
|
|
logger.warning("No pipeline diagram will be saved.")
|
|
raise PipelineDrawingError(f"There was an issue with {server_url}, see the stacktrace for details.") from exc
|
|
|
|
# Validate the response before it gets written to disk by the caller, so that a misconfigured
|
|
# or malicious server cannot cause arbitrary content to be saved to the output path.
|
|
_validate_image_response(resp, params)
|
|
|
|
return resp.content
|
|
|
|
|
|
def _to_mermaid_text(
|
|
graph: networkx.MultiDiGraph, init_params: str | dict, super_component_mapping: dict[str, str] | None = None
|
|
) -> str:
|
|
"""
|
|
Converts a Networkx graph into Mermaid syntax.
|
|
|
|
The output of this function can be used in the documentation with `mermaid` codeblocks and will be
|
|
automatically rendered.
|
|
|
|
:param graph: The graph to convert to Mermaid syntax
|
|
:param init_params: Initialization parameters for Mermaid
|
|
:param super_component_mapping: Mapping of component names to super component names
|
|
"""
|
|
# Copy the graph to avoid modifying the original
|
|
graph = _prepare_for_drawing(graph.copy())
|
|
sockets = {
|
|
comp: "".join(
|
|
[
|
|
f"<li>{name} ({_type_name(socket.type)})</li>"
|
|
for name, socket in data.get("input_sockets", {}).items()
|
|
if (not socket.is_mandatory and not socket.senders) or socket.is_variadic
|
|
]
|
|
)
|
|
for comp, data in graph.nodes(data=True)
|
|
}
|
|
optional_inputs = {
|
|
comp: f"<br><br>Optional inputs:<ul style='text-align:left;'>{sockets}</ul>" if sockets else ""
|
|
for comp, sockets in sockets.items()
|
|
}
|
|
|
|
# Create node definitions
|
|
states = {}
|
|
super_component_components = super_component_mapping.keys() if super_component_mapping else {}
|
|
|
|
# color variations for super components
|
|
super_component_colors = {}
|
|
if super_component_components:
|
|
unique_super_components = set(super_component_mapping.values()) # type:ignore
|
|
color_variations = generate_color_variations(n=len(unique_super_components))
|
|
super_component_colors = dict(zip(unique_super_components, color_variations, strict=True))
|
|
|
|
# Generate style definitions for each super component
|
|
style_definitions = []
|
|
for super_comp, color in super_component_colors.items():
|
|
style_definitions.append(f"classDef {super_comp} fill:{color},color:white;")
|
|
|
|
for comp, data in graph.nodes(data=True):
|
|
if comp in ["input", "output"]:
|
|
continue
|
|
|
|
# styling based on whether the component is a SuperComponent
|
|
if comp in super_component_components:
|
|
super_component_name = super_component_mapping[comp] # type:ignore
|
|
style = super_component_name
|
|
else:
|
|
style = "component"
|
|
node_def = f'{comp}["<b>{comp}</b><br><small><i>{type(data["instance"]).__name__}{optional_inputs[comp]}</i></small>"]:::{style}' # noqa: E501
|
|
states[comp] = node_def
|
|
|
|
connections_list = []
|
|
for from_comp, to_comp, conn_data in graph.edges(data=True):
|
|
if from_comp != "input" and to_comp != "output":
|
|
arrowtail = ARROWTAIL_MANDATORY if conn_data["mandatory"] else ARROWTAIL_OPTIONAL
|
|
arrowhead = ARROWHEAD_MANDATORY if conn_data["mandatory"] else ARROWHEAD_OPTIONAL
|
|
label = f'"{conn_data["label"]}<br><small><i>{conn_data["conn_type"]}</i></small>"'
|
|
conn_string = f"{states[from_comp]} {arrowtail} {label} {arrowhead} {states[to_comp]}"
|
|
connections_list.append(conn_string)
|
|
|
|
input_connections = [
|
|
f'i{{*}}--"{conn_data["label"]}<br><small><i>{conn_data["conn_type"]}</i></small>"--> {states[to_comp]}'
|
|
for _, to_comp, conn_data in graph.out_edges("input", data=True)
|
|
]
|
|
output_connections = [
|
|
f'{states[from_comp]}--"{conn_data["label"]}<br><small><i>{conn_data["conn_type"]}</i></small>"--> o{{*}}'
|
|
for from_comp, _, conn_data in graph.in_edges("output", data=True)
|
|
]
|
|
connections = "\n".join(connections_list + input_connections + output_connections)
|
|
|
|
# Create legend
|
|
legend_nodes = []
|
|
if super_component_colors:
|
|
legend_nodes.append("subgraph Legend")
|
|
for super_comp in super_component_colors:
|
|
legend_id = f"legend_{super_comp}"
|
|
legend_nodes.append(f'{legend_id}["{super_comp}"]:::{super_comp}')
|
|
legend_nodes.append("end")
|
|
connections += "\n" + "\n".join(legend_nodes)
|
|
|
|
# Add style definitions to the template
|
|
graph_styled = MERMAID_STYLED_TEMPLATE.format(
|
|
params=init_params, connections=connections, style_definitions="\n".join(style_definitions)
|
|
)
|
|
logger.debug("Mermaid diagram:\n{diagram}", diagram=graph_styled)
|
|
|
|
return graph_styled
|