diff --git a/python/semantic_kernel/schema/kernel_json_schema_builder.py b/python/semantic_kernel/schema/kernel_json_schema_builder.py index 5ec519b5b377..5d5933088c22 100644 --- a/python/semantic_kernel/schema/kernel_json_schema_builder.py +++ b/python/semantic_kernel/schema/kernel_json_schema_builder.py @@ -89,12 +89,17 @@ def build_model_schema( 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 + field_description = getattr(field_info, "description", None) + if field_description is None: + metadata = field_info.metadata + if isinstance(metadata, dict): + field_description = metadata.get("description") + elif isinstance(metadata, (list, tuple)): + for entry in metadata: + candidate = entry.get("description") if isinstance(entry, dict) else entry + if isinstance(candidate, str): + field_description = candidate + break if not cls._is_optional(field_type): required.append(field_name) properties[field_name] = cls.build(field_type, field_description, structured_output) diff --git a/python/tests/unit/schema/test_schema_builder.py b/python/tests/unit/schema/test_schema_builder.py index 5d24a599c96c..89594a110aef 100644 --- a/python/tests/unit/schema/test_schema_builder.py +++ b/python/tests/unit/schema/test_schema_builder.py @@ -6,6 +6,7 @@ from unittest.mock import Mock import pytest +from pydantic import Field from semantic_kernel.connectors.utils.structured_output_schema import generate_structured_output_response_format_schema from semantic_kernel.kernel_pydantic import KernelBaseModel @@ -455,3 +456,47 @@ def test_build_schema_with_nonpydantic_structured_output(): } assert structured_output_schema == expected_schema + + +class ConstrainedParams(KernelBaseModel): + top_p: float = Field(default=0.9, ge=0.0, le=1.0, description="nucleus sampling") + + +def test_build_model_schema_with_field_constraints_keeps_description(): + # Regression test for https://github.com/microsoft/semantic-kernel/issues/14443: + # pydantic constraint objects (Ge, Le, ...) live in FieldInfo.metadata and + # must not end up in the field description. + schema = KernelJsonSchemaBuilder.build_model_schema(ConstrainedParams) + assert schema["properties"]["top_p"]["description"] == "nucleus sampling" + json.dumps(schema) # the schema must be JSON serializable + + +@pytest.mark.parametrize("metadata", ["Annotated description", {"description": "Annotated description"}]) +@pytest.mark.parametrize("container", [list, tuple]) +def test_build_model_schema_with_annotated_description(metadata, container): + class Params(KernelBaseModel): + value: Annotated[float, Field(ge=0), metadata] + + field = Params.model_fields["value"] + field.metadata = container(field.metadata) + schema = KernelJsonSchemaBuilder.build_model_schema(Params) + assert schema["properties"]["value"]["description"] == "Annotated description" + json.dumps(schema) + + +def test_build_model_schema_explicit_description_overrides_metadata(): + class Params(KernelBaseModel): + value: Annotated[float, "Fallback description", Field(ge=0, description="Explicit description")] + + schema = KernelJsonSchemaBuilder.build_model_schema(Params) + assert schema["properties"]["value"]["description"] == "Explicit description" + json.dumps(schema) + + +def test_build_model_schema_ignores_non_description_metadata(): + class Params(KernelBaseModel): + value: Annotated[float, Field(ge=0), {"description": 123}] + + schema = KernelJsonSchemaBuilder.build_model_schema(Params) + assert "description" not in schema["properties"]["value"] + json.dumps(schema)