241 lines
9.7 KiB
Python
241 lines
9.7 KiB
Python
# Copyright (c) Microsoft. All rights reserved.
|
|
|
|
import sys
|
|
import types
|
|
from enum import Enum
|
|
from typing import Any, Union, get_args, get_origin, get_type_hints
|
|
|
|
from semantic_kernel.const import PARSED_ANNOTATION_UNION_DELIMITER
|
|
from semantic_kernel.exceptions.function_exceptions import FunctionInvalidParameterConfiguration
|
|
from semantic_kernel.kernel_pydantic import KernelBaseModel
|
|
|
|
TYPE_MAPPING = {
|
|
int: "integer",
|
|
str: "string",
|
|
bool: "boolean",
|
|
float: "number",
|
|
list: "array",
|
|
dict: "object",
|
|
set: "array",
|
|
tuple: "array",
|
|
"int": "integer",
|
|
"str": "string",
|
|
"bool": "boolean",
|
|
"float": "number",
|
|
"list": "array",
|
|
"dict": "object",
|
|
"set": "array",
|
|
"tuple": "array",
|
|
"object": "object",
|
|
"array": "array",
|
|
}
|
|
|
|
|
|
class KernelJsonSchemaBuilder:
|
|
"""Kernel JSON schema builder."""
|
|
|
|
@classmethod
|
|
def build(
|
|
cls, parameter_type: type | str | Any, description: str | None = None, structured_output: bool = False
|
|
) -> dict[str, Any]:
|
|
"""Builds the JSON schema for a given parameter type and description.
|
|
|
|
Args:
|
|
parameter_type: The parameter type.
|
|
description: The description of the parameter. Defaults to None.
|
|
structured_output: Whether the outputs are structured. Defaults to False.
|
|
|
|
Returns:
|
|
dict[str, Any]: The JSON schema for the parameter type.
|
|
"""
|
|
if isinstance(parameter_type, str):
|
|
return cls.build_from_type_name(parameter_type, description)
|
|
if isinstance(parameter_type, KernelBaseModel):
|
|
return cls.build_model_schema(parameter_type, description, structured_output)
|
|
if isinstance(parameter_type, type) and issubclass(parameter_type, Enum):
|
|
return cls.build_enum_schema(parameter_type, description)
|
|
if hasattr(parameter_type, "__annotations__"):
|
|
return cls.build_model_schema(parameter_type, description, structured_output)
|
|
if hasattr(parameter_type, "__args__"):
|
|
return cls.handle_complex_type(parameter_type, description, structured_output)
|
|
schema = cls.get_json_schema(parameter_type)
|
|
if description:
|
|
schema["description"] = description
|
|
return schema
|
|
|
|
@classmethod
|
|
def build_model_schema(
|
|
cls, model: type | KernelBaseModel, description: str | None = None, structured_output: bool = False
|
|
) -> dict[str, Any]:
|
|
"""Builds the JSON schema for a given model and description.
|
|
|
|
Args:
|
|
model: The model type.
|
|
description: The description of the model. Defaults to None.
|
|
structured_output: Whether the outputs are structured. Defaults to False.
|
|
|
|
Returns:
|
|
dict[str, Any]: The JSON schema for the model.
|
|
"""
|
|
# TODO (moonbox3): add support for handling forward references, which is not currently tested
|
|
# https://github.com/microsoft/semantic-kernel/issues/6464
|
|
properties = {}
|
|
required = []
|
|
|
|
model_module_globals = vars(sys.modules[model.__module__])
|
|
hints = get_type_hints(model, globalns=model_module_globals, localns={})
|
|
|
|
for field_name, field_type in hints.items():
|
|
field_description = None
|
|
if hasattr(model, "model_fields") and field_name in model.model_fields:
|
|
field_info = model.model_fields[field_name]
|
|
if isinstance(field_info.metadata, dict):
|
|
field_description = field_info.metadata.get("description")
|
|
elif isinstance(field_info.metadata, list) and field_info.metadata:
|
|
field_description = field_info.metadata[0]
|
|
elif hasattr(field_info, "description"):
|
|
field_description = field_info.description
|
|
if not cls._is_optional(field_type):
|
|
required.append(field_name)
|
|
properties[field_name] = cls.build(field_type, field_description, structured_output)
|
|
|
|
schema = {"type": "object", "properties": properties}
|
|
if required:
|
|
schema["required"] = required
|
|
if structured_output:
|
|
schema["additionalProperties"] = False # type: ignore
|
|
if description:
|
|
schema["description"] = description
|
|
|
|
return schema
|
|
|
|
@classmethod
|
|
def _is_optional(cls, field_type: Any) -> bool:
|
|
return get_origin(field_type) in {types.UnionType, Union} and type(None) in get_args(field_type)
|
|
|
|
@classmethod
|
|
def build_from_type_name(cls, parameter_type: str, description: str | None = None) -> dict[str, Any]:
|
|
"""Builds the JSON schema for a given parameter type name and description.
|
|
|
|
Args:
|
|
parameter_type (str): The parameter type name.
|
|
description (str, optional): The description of the parameter. Defaults to None.
|
|
|
|
Returns:
|
|
dict[str, Any]: The JSON schema for the parameter type.
|
|
"""
|
|
schema: dict[str, Any] = {}
|
|
if PARSED_ANNOTATION_UNION_DELIMITER in parameter_type:
|
|
# this means it is a Union or | so need to build with "anyOf"
|
|
types = parameter_type.split(PARSED_ANNOTATION_UNION_DELIMITER)
|
|
schemas = [cls.build_from_type_name(t.strip(), description) for t in types]
|
|
schema["anyOf"] = schemas
|
|
else:
|
|
type_name = TYPE_MAPPING.get(parameter_type, "object")
|
|
schema["type"] = type_name
|
|
if description:
|
|
schema["description"] = description
|
|
return schema
|
|
|
|
@classmethod
|
|
def get_json_schema(cls, parameter_type: type) -> dict[str, Any]:
|
|
"""Gets JSON schema for a given parameter type.
|
|
|
|
Args:
|
|
parameter_type (type): The parameter type.
|
|
|
|
Returns:
|
|
dict[str, Any]: The JSON schema for the parameter type.
|
|
"""
|
|
type_name = TYPE_MAPPING.get(parameter_type, "object")
|
|
return {"type": type_name}
|
|
|
|
@classmethod
|
|
def handle_complex_type(
|
|
cls, parameter_type: type, description: str | None = None, structured_output: bool = False
|
|
) -> dict[str, Any]:
|
|
"""Handles building the JSON schema for complex types.
|
|
|
|
Args:
|
|
parameter_type: The parameter type.
|
|
description: The description of the parameter. Defaults to None.
|
|
structured_output: Whether the outputs are structured. Defaults to False.
|
|
|
|
Returns:
|
|
dict[str, Any]: The JSON schema for the parameter type.
|
|
"""
|
|
origin = get_origin(parameter_type)
|
|
args = get_args(parameter_type)
|
|
|
|
schema: dict[str, Any] = {}
|
|
if origin is list or origin is set:
|
|
item_type = args[0]
|
|
schema = {
|
|
"type": "array",
|
|
"items": cls.build(item_type, structured_output=structured_output),
|
|
}
|
|
if description:
|
|
schema["description"] = description
|
|
return schema
|
|
if origin is dict:
|
|
_, value_type = args
|
|
additional_properties = cls.build(value_type, structured_output=structured_output)
|
|
if additional_properties == {"type": "object"}:
|
|
additional_properties["properties"] = {} # Account for differences in Python 3.10 dict
|
|
schema = {"type": "object", "additionalProperties": additional_properties}
|
|
if description:
|
|
schema["description"] = description
|
|
if structured_output:
|
|
schema["additionalProperties"] = False
|
|
return schema
|
|
if origin is tuple:
|
|
items = [cls.build(arg, structured_output=structured_output) for arg in args]
|
|
schema = {"type": "array", "items": items}
|
|
if description:
|
|
schema["description"] = description
|
|
if structured_output:
|
|
schema["additionalProperties"] = False
|
|
return schema
|
|
if origin in {Union, types.UnionType}:
|
|
# Handle Optional[T] (Union[T, None]) by making schema nullable
|
|
if len(args) == 2 and type(None) in args:
|
|
non_none_type = args[0] if args[1] is type(None) else args[1]
|
|
schema = cls.build(non_none_type, structured_output=structured_output)
|
|
schema["type"] = [schema["type"], "null"]
|
|
if description:
|
|
schema["description"] = description
|
|
if structured_output:
|
|
schema["additionalProperties"] = False
|
|
return schema
|
|
schemas = [cls.build(arg, description, structured_output=structured_output) for arg in args]
|
|
return {"anyOf": schemas}
|
|
schema = cls.get_json_schema(parameter_type)
|
|
if description:
|
|
schema["description"] = description
|
|
if structured_output:
|
|
schema["additionalProperties"] = False
|
|
return schema
|
|
|
|
@classmethod
|
|
def build_enum_schema(cls, enum_type: type, description: str | None = None) -> dict[str, Any]:
|
|
"""Builds the JSON schema for an enum type.
|
|
|
|
Args:
|
|
enum_type (type): The enum type.
|
|
description (str, optional): The description of the enum. Defaults to None.
|
|
|
|
Returns:
|
|
dict[str, Any]: The JSON schema for the enum type.
|
|
"""
|
|
if not issubclass(enum_type, Enum):
|
|
raise FunctionInvalidParameterConfiguration(f"{enum_type} is not a valid Enum type")
|
|
|
|
try:
|
|
enum_values = [item.value for item in enum_type]
|
|
except TypeError as ex:
|
|
raise FunctionInvalidParameterConfiguration(f"Failed to get enum values for {enum_type}") from ex
|
|
schema = {"type": TYPE_MAPPING.get(type(enum_values[0]), "string"), "enum": enum_values}
|
|
if description:
|
|
schema["description"] = description
|
|
return schema
|