Coverage for haystack/tools/parameters_schema_utils.py: 97%
97 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:53 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:53 +0000
1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2#
3# SPDX-License-Identifier: Apache-2.0
5import collections
6import types
7from collections.abc import Callable, Sequence
8from collections.abc import Callable as ABCCallable
9from dataclasses import MISSING, fields, is_dataclass
10from inspect import getdoc
11from types import NoneType
12from typing import Any, Union, get_args, get_origin
14from docstring_parser import parse
15from pydantic import BaseModel, Field, create_model
17from haystack import logging
18from haystack.dataclasses import ChatMessage
19from haystack.utils.type_serialization import _is_union_type
21logger = logging.getLogger(__name__)
24def _unwrap_optional(type_hint: Any) -> Any:
25 """
26 Unwrap Optional types (i.e. ``X | None`` or ``Optional[X]``) to get the inner type.
28 :param type_hint: The type hint to unwrap.
29 :returns: The inner type if ``type_hint`` is ``Optional[X]``, otherwise ``type_hint`` unchanged.
30 """
31 origin = get_origin(type_hint)
32 if origin is Union or origin is types.UnionType:
33 non_none = [a for a in get_args(type_hint) if a is not NoneType]
34 if len(non_none) == 1:
35 return non_none[0]
36 return type_hint
39def _contains_callable_type(type_hint: Any) -> bool:
40 """
41 Check if a type hint contains a Callable type, including within Union types.
43 The purpose of this function is to help identify Callable types so they can
44 be skipped during schema generation.
46 :param type_hint: The type hint to check.
47 :returns: True if the type contains a Callable, False otherwise.
48 """
49 origin = get_origin(type_hint)
51 # Check if it's a Callable type (direct or parameterized)
52 if type_hint in (Callable, ABCCallable) or origin in (Callable, ABCCallable):
53 return True
55 # Recursively check Union types (both typing.Union and types.UnionType for `X | Y` syntax)
56 if origin in (Union, types.UnionType):
57 return any(_contains_callable_type(arg) for arg in get_args(type_hint))
59 return False
62# Schema placeholder models for Tool and Toolset
63# These are used during JSON schema generation to represent non-serializable types
64class _ToolSchemaPlaceholder(BaseModel):
65 """Placeholder model representing a Tool for JSON schema generation."""
67 name: str = Field(description="Name of the tool")
68 description: str = Field(description="Description of the tool")
69 parameters: dict[str, Any] = Field(description="JSON schema of the tool parameters")
72class _ToolsetSchemaPlaceholder(BaseModel):
73 """Placeholder model representing a Toolset for JSON schema generation."""
75 tools: list[_ToolSchemaPlaceholder] = Field(description="List of tools in the toolset")
78def _get_param_descriptions(method: Callable) -> tuple[str, dict[str, str]]:
79 """
80 Extracts parameter descriptions from the method's docstring using docstring_parser.
82 :param method: The method to extract parameter descriptions from.
83 :returns:
84 A tuple including the short description of the method and a dictionary mapping parameter names to their
85 descriptions.
86 """
87 docstring = getdoc(method)
88 if not docstring:
89 return "", {}
91 parsed_doc = parse(docstring)
92 param_descriptions = {}
93 for param in parsed_doc.params:
94 if not param.description:
95 logger.warning(
96 "Missing description for parameter '{arg_name}'. Please add a description in the component's "
97 "run() method docstring using the format ':param {arg_name}: <description>'. "
98 "This description helps the LLM understand how to use this parameter.",
99 arg_name=param.arg_name,
100 )
101 param_descriptions[param.arg_name] = param.description.strip() if param.description else ""
102 return parsed_doc.short_description or "", param_descriptions
105def _get_component_param_descriptions(component: Any) -> dict[str, str]:
106 """
107 Get parameter descriptions from a component, handling both regular Components and SuperComponents.
109 For regular components, this extracts descriptions from the run method's docstring.
110 For SuperComponents, this extracts descriptions from the underlying pipeline components.
112 :param component: The component to extract parameter descriptions from
113 :returns: A dictionary mapping parameter names to their descriptions
114 """
115 from haystack.core.super_component.super_component import _SuperComponent
117 # Get descriptions from the component's run method
118 _, param_descriptions = _get_param_descriptions(component.run)
120 # If it's a SuperComponent, enhance the parameter descriptions from the original components
121 if isinstance(component, _SuperComponent):
122 for super_param_name, pipeline_paths in component.input_mapping.items():
123 # Collect descriptions from all mapped components
124 descriptions = []
125 for path in pipeline_paths:
126 try:
127 # Get the component and socket this input is mapped from
128 comp_name, socket_name = component._split_component_path(path)
129 pipeline_component = component.pipeline.get_component(comp_name)
131 # Add parameter description if available
132 _, run_param_descriptions = _get_param_descriptions(pipeline_component.run)
133 if input_param_mapping := run_param_descriptions.get(socket_name):
134 descriptions.append(f"Provided to the '{comp_name}' component as: '{input_param_mapping}'")
135 except Exception as e:
136 logger.debug(
137 "Error extracting description for {super_param_name} from {path}: {e}",
138 super_param_name=super_param_name,
139 path=path,
140 e=str(e),
141 )
143 # A single SuperComponent input can map to multiple pipeline components, e.g.
144 # input_mapping={"combined_input": ["comp_a.query", "comp_b.text"]}
145 if descriptions:
146 param_descriptions[super_param_name] = ", and ".join(descriptions) + "."
148 return param_descriptions
151def _dataclass_to_pydantic_model(dc_type: Any) -> type[BaseModel]:
152 """
153 Convert a Python dataclass to an equivalent Pydantic model.
155 :param dc_type: The dataclass type to convert.
156 :returns:
157 A dynamically generated Pydantic model class with fields and types derived from the dataclass definition.
158 Field descriptions are extracted from docstrings when available.
159 """
160 _, param_descriptions = _get_param_descriptions(dc_type)
161 cls = dc_type if isinstance(dc_type, type) else dc_type.__class__
163 field_defs: dict[str, Any] = {}
164 for field in fields(dc_type):
165 f_type = field.type if isinstance(field.type, str) else _resolve_type(field.type)
166 default = field.default if field.default is not MISSING else ...
167 default = field.default_factory() if callable(field.default_factory) else default
169 # Special handling for ChatMessage since pydantic doesn't allow for field names with leading underscores
170 field_name = field.name
171 if dc_type is ChatMessage and field_name.startswith("_"):
172 # We remove the underscore since ChatMessage.from_dict does allow for field names without the underscore
173 field_name = field_name[1:]
175 description = param_descriptions.get(field_name, f"Field '{field_name}' of '{cls.__name__}'.")
176 field_defs[field_name] = (f_type, Field(default, description=description))
178 return create_model(cls.__name__, **field_defs)
181def _resolve_type(_type: Any) -> Any: # noqa: PLR0911
182 """
183 Recursively resolve and convert complex type annotations, transforming dataclasses into Pydantic-compatible types.
185 This function walks through nested type annotations (e.g., List, Dict, Union) and converts any dataclass types
186 it encounters into corresponding Pydantic models.
188 :param _type: The type annotation to resolve. If the type is a dataclass, it will be converted to a Pydantic model.
189 For generic types (like list[SomeDataclass]), the inner types are also resolved recursively.
191 :returns:
192 A fully resolved type, with all dataclass types converted to Pydantic models
193 """
194 # Special handling for Tool and Toolset types - replace with schema placeholders
195 # These types contain Callables which cannot be serialized to JSON Schema
196 from haystack.tools.tool import Tool
197 from haystack.tools.toolset import Toolset
199 if _type is Tool:
200 return _ToolSchemaPlaceholder
202 if _type is Toolset:
203 return _ToolsetSchemaPlaceholder
205 if is_dataclass(_type):
206 return _dataclass_to_pydantic_model(_type)
208 origin = get_origin(_type)
209 args = get_args(_type)
211 if origin is list:
212 return list[_resolve_type(args[0]) if args else Any] # type: ignore[misc]
214 if origin is collections.abc.Sequence:
215 return Sequence[_resolve_type(args[0]) if args else Any] # type: ignore[misc]
217 if _is_union_type(origin):
218 return Union[tuple(_resolve_type(a) for a in args)]
220 if origin is dict:
221 return dict[args[0] if args else Any, _resolve_type(args[1]) if args else Any] # type: ignore[misc]
223 return _type