mirror of
https://github.com/microsoft/semantic-kernel.git
synced 2026-10-12 11:19:56 +00:00
### Motivation and Context <!-- Thank you for your contribution to the semantic-kernel repo! Please help reviewers and future users, providing the following information: 1. Why is this change required? 2. What problem does it solve? 3. What scenario does it contribute to? 4. If it fixes an open issue, please link to the issue here. --> Resolves: https://github.com/microsoft/semantic-kernel/issues/11821 Today, the encoding of template arguments is performed only if argument type is `string`. In case of custom type, anonymous type or collection - the encoding is not performed. This PR contains changes to throw an exception in case if encoding is enabled but complex type is used. In case of complex type, the encoding should be performed manually according to business logic and automatic encoding should be explicitly disabled. This enforces stricter, but more secure template rendering rules. **Note**: this is a breaking change for customers who use Handlebars or Liquid template with complex type arguments. Code changes are required when initializing template arguments: ```diff var arguments = new KernelArguments() { { "customer", new { - firstName = userInput.FirstName, - lastName = userInput.LastName, + firstName = HttpUtility.HtmlEncode(userInput.FirstName), + lastName = HttpUtility.HtmlEncode(userInput.LastName), } } }; var templateFactory = new LiquidPromptTemplateFactory(); var promptTemplateConfig = new PromptTemplateConfig() { TemplateFormat = "liquid" + InputVariables = new() + { + // We set AllowDangerouslySetContent to 'true' because each property of this argument is encoded manually. + new() { Name = "customer", AllowDangerouslySetContent = true }, + } }; var promptTemplate = templateFactory.Create(promptTemplateConfig); var renderedPrompt = await promptTemplate.RenderAsync(kernel, arguments); ``` ### Contribution Checklist <!-- Before submitting this PR, please make sure: --> - [x] The code builds clean without any errors or warnings - [x] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [x] All unit tests pass, and I have added new tests where possible - [ ] I didn't break anyone 😄
417 lines
20 KiB
Python
417 lines
20 KiB
Python
# Copyright (c) Microsoft. All rights reserved.
|
|
|
|
import logging
|
|
import os
|
|
from collections.abc import AsyncGenerator, Mapping, Sequence
|
|
from html import unescape
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import yaml
|
|
from pydantic import Field, ValidationError, model_validator
|
|
|
|
from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase
|
|
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
|
|
from semantic_kernel.connectors.ai.text_completion_client_base import TextCompletionClientBase
|
|
from semantic_kernel.connectors.ai.text_to_audio_client_base import TextToAudioClientBase
|
|
from semantic_kernel.connectors.ai.text_to_image_client_base import TextToImageClientBase
|
|
from semantic_kernel.const import DEFAULT_SERVICE_NAME
|
|
from semantic_kernel.contents.audio_content import AudioContent
|
|
from semantic_kernel.contents.chat_history import ChatHistory
|
|
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
|
from semantic_kernel.contents.image_content import ImageContent
|
|
from semantic_kernel.contents.text_content import TextContent
|
|
from semantic_kernel.exceptions import FunctionExecutionException, FunctionInitializationError
|
|
from semantic_kernel.exceptions.function_exceptions import PromptRenderingException
|
|
from semantic_kernel.filters.filter_types import FilterTypes
|
|
from semantic_kernel.filters.functions.function_invocation_context import FunctionInvocationContext
|
|
from semantic_kernel.filters.kernel_filters_extension import _rebuild_prompt_render_context
|
|
from semantic_kernel.filters.prompts.prompt_render_context import PromptRenderContext
|
|
from semantic_kernel.functions.function_result import FunctionResult
|
|
from semantic_kernel.functions.kernel_arguments import KernelArguments
|
|
from semantic_kernel.functions.kernel_function import TEMPLATE_FORMAT_MAP, KernelFunction
|
|
from semantic_kernel.functions.kernel_function_metadata import KernelFunctionMetadata
|
|
from semantic_kernel.functions.kernel_parameter_metadata import KernelParameterMetadata
|
|
from semantic_kernel.functions.prompt_rendering_result import PromptRenderingResult
|
|
from semantic_kernel.prompt_template.const import KERNEL_TEMPLATE_FORMAT_NAME, TEMPLATE_FORMAT_TYPES
|
|
from semantic_kernel.prompt_template.prompt_template_base import PromptTemplateBase
|
|
from semantic_kernel.prompt_template.prompt_template_config import PromptTemplateConfig
|
|
|
|
if TYPE_CHECKING:
|
|
from semantic_kernel.services.ai_service_client_base import AIServiceClientBase
|
|
|
|
logger: logging.Logger = logging.getLogger(__name__)
|
|
|
|
PROMPT_FILE_NAME = "skprompt.txt"
|
|
CONFIG_FILE_NAME = "config.json"
|
|
PROMPT_RETURN_PARAM = KernelParameterMetadata(
|
|
name="return",
|
|
description="The completion result",
|
|
default_value=None,
|
|
type="FunctionResult", # type: ignore
|
|
is_required=True,
|
|
)
|
|
|
|
|
|
class KernelFunctionFromPrompt(KernelFunction):
|
|
"""Semantic Kernel Function from a prompt."""
|
|
|
|
prompt_template: PromptTemplateBase
|
|
prompt_execution_settings: dict[str, PromptExecutionSettings] = Field(default_factory=dict)
|
|
|
|
def __init__(
|
|
self,
|
|
function_name: str,
|
|
plugin_name: str | None = None,
|
|
description: str | None = None,
|
|
prompt: str | None = None,
|
|
template_format: TEMPLATE_FORMAT_TYPES = KERNEL_TEMPLATE_FORMAT_NAME,
|
|
prompt_template: PromptTemplateBase | None = None,
|
|
prompt_template_config: PromptTemplateConfig | None = None,
|
|
prompt_execution_settings: PromptExecutionSettings
|
|
| Sequence[PromptExecutionSettings]
|
|
| Mapping[str, PromptExecutionSettings]
|
|
| None = None,
|
|
) -> None:
|
|
"""Initializes a new instance of the KernelFunctionFromPrompt class.
|
|
|
|
Args:
|
|
function_name (str): The name of the function
|
|
plugin_name (str): The name of the plugin
|
|
description (str): The description for the function
|
|
|
|
prompt (Optional[str]): The prompt
|
|
template_format (Optional[str]): The template format, default is "semantic-kernel"
|
|
prompt_template (Optional[KernelPromptTemplate]): The prompt template
|
|
prompt_template_config (Optional[PromptTemplateConfig]): The prompt template configuration
|
|
prompt_execution_settings (Optional): instance, list or dict of PromptExecutionSettings to be used
|
|
by the function, can also be supplied through prompt_template_config,
|
|
but the supplied one is used if both are present.
|
|
prompt_template_config (Optional[PromptTemplateConfig]): the prompt template config.
|
|
"""
|
|
if not prompt and not prompt_template_config and not prompt_template:
|
|
raise FunctionInitializationError(
|
|
"The prompt cannot be empty, must be supplied directly, \
|
|
through prompt_template_config or in the prompt_template."
|
|
)
|
|
|
|
if prompt and prompt_template_config and prompt_template_config.template != prompt:
|
|
logger.warning(
|
|
f"Prompt ({prompt}) and PromptTemplateConfig ({prompt_template_config.template}) both supplied, "
|
|
"using the template in PromptTemplateConfig, ignoring prompt."
|
|
)
|
|
if template_format and prompt_template_config and prompt_template_config.template_format != template_format:
|
|
logger.warning(
|
|
f"Template ({template_format}) and PromptTemplateConfig ({prompt_template_config.template_format}) "
|
|
"both supplied, using the template format in PromptTemplateConfig, ignoring template."
|
|
)
|
|
if not prompt_template:
|
|
if not prompt_template_config:
|
|
# prompt must be there if prompt_template and prompt_template_config is not supplied
|
|
prompt_template_config = PromptTemplateConfig(
|
|
name=function_name,
|
|
description=description,
|
|
template=prompt,
|
|
template_format=template_format,
|
|
)
|
|
elif not prompt_template_config.template:
|
|
prompt_template_config.template = prompt
|
|
prompt_template = TEMPLATE_FORMAT_MAP[prompt_template_config.template_format](
|
|
prompt_template_config=prompt_template_config
|
|
) # type: ignore
|
|
|
|
try:
|
|
metadata = KernelFunctionMetadata(
|
|
name=function_name,
|
|
plugin_name=plugin_name,
|
|
description=description,
|
|
parameters=prompt_template.prompt_template_config.get_kernel_parameter_metadata(), # type: ignore
|
|
is_prompt=True,
|
|
is_asynchronous=True,
|
|
return_parameter=PROMPT_RETURN_PARAM,
|
|
)
|
|
except ValidationError as exc:
|
|
raise FunctionInitializationError("Failed to create KernelFunctionMetadata") from exc
|
|
super().__init__(
|
|
metadata=metadata,
|
|
prompt_template=prompt_template, # type: ignore
|
|
prompt_execution_settings=prompt_execution_settings or {}, # type: ignore
|
|
)
|
|
|
|
@model_validator(mode="before")
|
|
@classmethod
|
|
def rewrite_execution_settings(
|
|
cls,
|
|
data: Any,
|
|
) -> dict[str, PromptExecutionSettings]:
|
|
"""Rewrite execution settings to a dictionary.
|
|
|
|
If the prompt_execution_settings is not a dictionary, it is converted to a dictionary.
|
|
If it is not supplied, but prompt_template is, the prompt_template's execution settings are used.
|
|
"""
|
|
if isinstance(data, dict):
|
|
prompt_execution_settings = data.get("prompt_execution_settings")
|
|
prompt_template = data.get("prompt_template")
|
|
if not prompt_execution_settings:
|
|
if prompt_template:
|
|
prompt_execution_settings = prompt_template.prompt_template_config.execution_settings
|
|
data["prompt_execution_settings"] = prompt_execution_settings
|
|
if not prompt_execution_settings:
|
|
return data
|
|
if isinstance(prompt_execution_settings, PromptExecutionSettings):
|
|
data["prompt_execution_settings"] = {
|
|
prompt_execution_settings.service_id or DEFAULT_SERVICE_NAME: prompt_execution_settings
|
|
}
|
|
if isinstance(prompt_execution_settings, Sequence):
|
|
data["prompt_execution_settings"] = {
|
|
s.service_id or DEFAULT_SERVICE_NAME: s for s in prompt_execution_settings
|
|
}
|
|
return data
|
|
|
|
async def _invoke_internal(self, context: FunctionInvocationContext) -> None:
|
|
"""Invokes the function with the given arguments."""
|
|
prompt_render_result = await self._render_prompt(context)
|
|
if prompt_render_result.function_result is not None:
|
|
context.result = prompt_render_result.function_result
|
|
return
|
|
|
|
if isinstance(prompt_render_result.ai_service, ChatCompletionClientBase):
|
|
chat_history = ChatHistory.from_rendered_prompt(prompt_render_result.rendered_prompt)
|
|
try:
|
|
chat_message_contents = await prompt_render_result.ai_service.get_chat_message_contents(
|
|
chat_history=chat_history,
|
|
settings=prompt_render_result.execution_settings,
|
|
**{"kernel": context.kernel, "arguments": context.arguments},
|
|
)
|
|
except Exception as exc:
|
|
raise FunctionExecutionException(f"Error occurred while invoking function {self.name}: {exc}") from exc
|
|
|
|
if not chat_message_contents:
|
|
raise FunctionExecutionException(f"No completions returned while invoking function {self.name}")
|
|
|
|
context.result = self._create_function_result(
|
|
completions=chat_message_contents,
|
|
chat_history=chat_history,
|
|
arguments=context.arguments,
|
|
prompt=prompt_render_result.rendered_prompt,
|
|
)
|
|
return
|
|
|
|
if isinstance(prompt_render_result.ai_service, TextCompletionClientBase):
|
|
try:
|
|
texts = await prompt_render_result.ai_service.get_text_contents(
|
|
prompt=unescape(prompt_render_result.rendered_prompt),
|
|
settings=prompt_render_result.execution_settings,
|
|
)
|
|
except Exception as exc:
|
|
raise FunctionExecutionException(f"Error occurred while invoking function {self.name}: {exc}") from exc
|
|
|
|
context.result = self._create_function_result(
|
|
completions=texts, arguments=context.arguments, prompt=prompt_render_result.rendered_prompt
|
|
)
|
|
return
|
|
|
|
if isinstance(prompt_render_result.ai_service, TextToImageClientBase):
|
|
try:
|
|
images = await prompt_render_result.ai_service.get_image_content(
|
|
description=unescape(prompt_render_result.rendered_prompt),
|
|
settings=prompt_render_result.execution_settings,
|
|
)
|
|
except Exception as exc:
|
|
raise FunctionExecutionException(f"Error occurred while invoking function {self.name}: {exc}") from exc
|
|
|
|
context.result = self._create_function_result(
|
|
completions=[images], arguments=context.arguments, prompt=prompt_render_result.rendered_prompt
|
|
)
|
|
return
|
|
|
|
if isinstance(prompt_render_result.ai_service, TextToAudioClientBase):
|
|
try:
|
|
audio = await prompt_render_result.ai_service.get_audio_content(
|
|
text=unescape(prompt_render_result.rendered_prompt),
|
|
settings=prompt_render_result.execution_settings,
|
|
)
|
|
except Exception as exc:
|
|
raise FunctionExecutionException(f"Error occurred while invoking function {self.name}: {exc}") from exc
|
|
|
|
context.result = self._create_function_result(
|
|
completions=[audio], arguments=context.arguments, prompt=prompt_render_result.rendered_prompt
|
|
)
|
|
return
|
|
|
|
raise ValueError(f"Service `{type(prompt_render_result.ai_service).__name__}` is not a valid AI service")
|
|
|
|
async def _invoke_internal_stream(self, context: FunctionInvocationContext) -> None:
|
|
"""Invokes the function stream with the given arguments."""
|
|
prompt_render_result = await self._render_prompt(context, is_streaming=True)
|
|
if prompt_render_result.function_result is not None:
|
|
context.result = prompt_render_result.function_result
|
|
return
|
|
|
|
if isinstance(prompt_render_result.ai_service, ChatCompletionClientBase):
|
|
chat_history = ChatHistory.from_rendered_prompt(prompt_render_result.rendered_prompt)
|
|
value: AsyncGenerator = prompt_render_result.ai_service.get_streaming_chat_message_contents(
|
|
chat_history=chat_history,
|
|
settings=prompt_render_result.execution_settings,
|
|
**{"kernel": context.kernel, "arguments": context.arguments},
|
|
)
|
|
elif isinstance(prompt_render_result.ai_service, TextCompletionClientBase):
|
|
value = prompt_render_result.ai_service.get_streaming_text_contents(
|
|
prompt=prompt_render_result.rendered_prompt, settings=prompt_render_result.execution_settings
|
|
)
|
|
else:
|
|
raise FunctionExecutionException(
|
|
f"Service `{type(prompt_render_result.ai_service)}` is not a valid AI service"
|
|
)
|
|
|
|
context.result = FunctionResult(
|
|
function=self.metadata, value=value, rendered_prompt=prompt_render_result.rendered_prompt
|
|
)
|
|
|
|
async def _render_prompt(
|
|
self, context: FunctionInvocationContext, is_streaming: bool = False
|
|
) -> PromptRenderingResult:
|
|
"""Render the prompt and apply the prompt rendering filters."""
|
|
self.update_arguments_with_defaults(context.arguments)
|
|
|
|
_rebuild_prompt_render_context()
|
|
prompt_render_context = PromptRenderContext(
|
|
function=self, kernel=context.kernel, arguments=context.arguments, is_streaming=is_streaming
|
|
)
|
|
|
|
stack = context.kernel.construct_call_stack(
|
|
filter_type=FilterTypes.PROMPT_RENDERING,
|
|
inner_function=self._inner_render_prompt,
|
|
)
|
|
await stack(prompt_render_context)
|
|
|
|
if prompt_render_context.rendered_prompt is None:
|
|
raise PromptRenderingException("Prompt rendering failed, no rendered prompt was returned.")
|
|
selected_service: tuple["AIServiceClientBase", PromptExecutionSettings] = context.kernel.select_ai_service(
|
|
function=self,
|
|
arguments=context.arguments,
|
|
type=(TextCompletionClientBase, ChatCompletionClientBase) if prompt_render_context.is_streaming else None,
|
|
)
|
|
return PromptRenderingResult(
|
|
rendered_prompt=prompt_render_context.rendered_prompt,
|
|
ai_service=selected_service[0],
|
|
execution_settings=selected_service[1],
|
|
function_result=prompt_render_context.function_result,
|
|
)
|
|
|
|
async def _inner_render_prompt(self, context: PromptRenderContext) -> None:
|
|
"""Render the prompt using the prompt template."""
|
|
context.rendered_prompt = await self.prompt_template.render(context.kernel, context.arguments)
|
|
|
|
def _create_function_result(
|
|
self,
|
|
completions: list[ChatMessageContent] | list[TextContent] | list[ImageContent] | list[AudioContent],
|
|
arguments: KernelArguments,
|
|
chat_history: ChatHistory | None = None,
|
|
prompt: str | None = None,
|
|
) -> FunctionResult:
|
|
"""Creates a function result with the given completions."""
|
|
metadata: dict[str, Any] = {
|
|
"arguments": arguments,
|
|
"metadata": [completion.metadata for completion in completions],
|
|
}
|
|
if chat_history:
|
|
metadata["messages"] = chat_history
|
|
if prompt:
|
|
metadata["prompt"] = prompt
|
|
return FunctionResult(
|
|
function=self.metadata,
|
|
value=completions,
|
|
metadata=metadata,
|
|
rendered_prompt=prompt,
|
|
)
|
|
|
|
def update_arguments_with_defaults(self, arguments: KernelArguments) -> None:
|
|
"""Update any missing values with their defaults."""
|
|
for parameter in self.prompt_template.prompt_template_config.input_variables:
|
|
if parameter.name not in arguments and parameter.default not in {None, "", False, 0}:
|
|
arguments[parameter.name] = parameter.default
|
|
|
|
@classmethod
|
|
def from_yaml(cls, yaml_str: str, plugin_name: str | None = None) -> "KernelFunctionFromPrompt":
|
|
"""Creates a new instance of the KernelFunctionFromPrompt class from a YAML string."""
|
|
try:
|
|
data = yaml.safe_load(yaml_str)
|
|
except yaml.YAMLError as exc: # pragma: no cover
|
|
raise FunctionInitializationError(f"Invalid YAML content: {yaml_str}, error: {exc}") from exc
|
|
|
|
if not isinstance(data, dict):
|
|
raise FunctionInitializationError(f"The YAML content must represent a dictionary, got {yaml_str}")
|
|
|
|
try:
|
|
prompt_template_config = PromptTemplateConfig(**data)
|
|
except ValidationError as exc:
|
|
raise FunctionInitializationError(
|
|
f"Error initializing PromptTemplateConfig: {exc} from yaml data: {data}"
|
|
) from exc
|
|
return cls(
|
|
function_name=prompt_template_config.name,
|
|
plugin_name=plugin_name,
|
|
description=prompt_template_config.description,
|
|
prompt_template_config=prompt_template_config,
|
|
template_format=prompt_template_config.template_format,
|
|
)
|
|
|
|
@classmethod
|
|
def from_directory(
|
|
cls, path: str, plugin_name: str | None = None, encoding: str = "utf-8"
|
|
) -> "KernelFunctionFromPrompt":
|
|
"""Creates a new instance of the KernelFunctionFromPrompt class from a directory.
|
|
|
|
The directory needs to contain:
|
|
- A prompt file named `skprompt.txt`
|
|
- A config file named `config.json`
|
|
|
|
Args:
|
|
path: The path to the directory containing the prompt and config files.
|
|
plugin_name: The name of the plugin.
|
|
encoding: The encoding to use when reading the files. Defaults to "utf-8".
|
|
|
|
Returns:
|
|
KernelFunctionFromPrompt: The kernel function from prompt
|
|
"""
|
|
prompt_path = os.path.join(path, PROMPT_FILE_NAME)
|
|
config_path = os.path.join(path, CONFIG_FILE_NAME)
|
|
prompt_exists = os.path.exists(prompt_path)
|
|
config_exists = os.path.exists(config_path)
|
|
if not config_exists and not prompt_exists:
|
|
raise FunctionInitializationError(
|
|
f"{PROMPT_FILE_NAME} and {CONFIG_FILE_NAME} files are required to create a "
|
|
f"function from a directory, path: {path!s}."
|
|
)
|
|
if not config_exists:
|
|
raise FunctionInitializationError(
|
|
f"{CONFIG_FILE_NAME} files are required to create a function from a directory, "
|
|
f"path: {path!s}, prompt file is there."
|
|
)
|
|
if not prompt_exists:
|
|
raise FunctionInitializationError(
|
|
f"{PROMPT_FILE_NAME} files are required to create a function from a directory, "
|
|
f"path: {path!s}, config file is there."
|
|
)
|
|
|
|
function_name = os.path.basename(path)
|
|
|
|
with open(config_path, encoding=encoding) as config_file:
|
|
prompt_template_config = PromptTemplateConfig.from_json(config_file.read())
|
|
prompt_template_config.name = function_name
|
|
|
|
with open(prompt_path, encoding=encoding) as prompt_file:
|
|
prompt_template_config.template = prompt_file.read()
|
|
|
|
prompt_template = TEMPLATE_FORMAT_MAP[prompt_template_config.template_format]( # type: ignore
|
|
prompt_template_config=prompt_template_config
|
|
)
|
|
return cls(
|
|
function_name=function_name,
|
|
plugin_name=plugin_name,
|
|
prompt_template=prompt_template,
|
|
prompt_template_config=prompt_template_config,
|
|
template_format=prompt_template_config.template_format,
|
|
description=prompt_template_config.description,
|
|
)
|