Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,19 @@ async def render(self, kernel: "Kernel", arguments: "KernelArguments | None" = N
str: The prompt template ready to be used for an AI request

"""
# Apply documented input-variable defaults for any variable that the
# caller did not supply. Without this, a declared default is validated
# (string-only) and exposed as metadata, but never actually used.
if self.prompt_template_config.input_variables:
if arguments is None:
arguments = KernelArguments()
for variable in self.prompt_template_config.input_variables:
if (
variable.name not in arguments
and variable.name.lower() not in {k.lower() for k in arguments}
and variable.default
):
arguments[variable.name] = variable.default
return await self.render_blocks(self._blocks, kernel, arguments)

async def render_blocks(
Expand Down
19 changes: 14 additions & 5 deletions python/semantic_kernel/schema/kernel_json_schema_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,20 @@ 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"):
# `FieldInfo.metadata` holds constraint objects (e.g. annotated_types.Ge)
# alongside any description metadata supplied through Annotated,
# so only strings and dicts can be descriptions; never use
# `metadata[0]` blindly as it is most often a constraint.
field_metadata = getattr(field_info, "metadata", None)
if isinstance(field_metadata, (list, tuple)):
for meta in field_metadata:
if isinstance(meta, str):
field_description = meta
break
if isinstance(meta, dict) and meta.get("description"):
field_description = meta["description"]
break
if field_description is None and field_info.description:
field_description = field_info.description
if not cls._is_optional(field_type):
required.append(field_name)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,29 @@ def my_function(arguments: KernelArguments) -> str:
target = create_kernel_prompt_template(template, allow_dangerously_set_content=True)
with pytest.raises(TemplateRenderException):
await target.render(kernel, arguments)


async def test_input_variable_default_used_when_argument_missing(kernel: Kernel):
config = PromptTemplateConfig(
name="test",
description="test",
template="Hello {{$name}}!",
template_format="semantic-kernel",
input_variables=[InputVariable(name="name", description="who", default="STRANGER")],
)
template = KernelPromptTemplate(prompt_template_config=config)
rendered = await template.render(kernel)
assert rendered == "Hello STRANGER!"


async def test_input_variable_default_does_not_override_argument(kernel: Kernel):
config = PromptTemplateConfig(
name="test",
description="test",
template="Hello {{$name}}!",
template_format="semantic-kernel",
input_variables=[InputVariable(name="name", description="who", default="STRANGER")],
)
template = KernelPromptTemplate(prompt_template_config=config)
rendered = await template.render(kernel, KernelArguments(name="World"))
assert rendered == "Hello World!"
38 changes: 38 additions & 0 deletions python/tests/unit/schema/test_schema_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,3 +455,41 @@ def test_build_schema_with_nonpydantic_structured_output():
}

assert structured_output_schema == expected_schema


def test_build_model_schema_field_description_with_constraints():
"""Field descriptions must survive when the field also has constraints.

Pydantic stores constraints (e.g. `ge=0`) in `FieldInfo.metadata`; taking
`metadata[0]` as the description leaks the constraint object into the
schema, which then cannot be JSON serialized when sent to the model.
"""
from pydantic import Field

class ModelWithConstraints(KernelBaseModel):
count: int = Field(description="number of items", ge=0)
ratio: float = Field(description="the ratio", gt=0, le=1)
label: str = Field(description="the label")

schema = KernelJsonSchemaBuilder.build(ModelWithConstraints)

assert schema["properties"]["count"]["description"] == "number of items"
assert schema["properties"]["ratio"]["description"] == "the ratio"
assert schema["properties"]["label"]["description"] == "the label"
# the full schema must stay JSON serializable (function calling payload)
assert json.loads(json.dumps(schema))


def test_build_model_schema_annotated_descriptions():
"""Descriptions supplied via Annotated metadata must be used, also with constraints."""
from pydantic import Field

class ModelWithAnnotated(KernelBaseModel):
amount: Annotated[int, "amount description"] = Field(ge=1)
tagged: Annotated[str, {"description": "tagged description"}] = "x"

schema = KernelJsonSchemaBuilder.build(ModelWithAnnotated)

assert schema["properties"]["amount"]["description"] == "amount description"
assert schema["properties"]["tagged"]["description"] == "tagged description"
assert json.loads(json.dumps(schema))
Loading