diff --git a/examples/platform/message-templates.php b/examples/platform/message-templates.php new file mode 100644 index 000000000..b9e9a16be --- /dev/null +++ b/examples/platform/message-templates.php @@ -0,0 +1,102 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; +use Symfony\AI\Platform\EventListener\TemplateRendererListener; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Message\Template; +use Symfony\AI\Platform\Message\TemplateRenderer\ChainTemplateRenderer; +use Symfony\AI\Platform\Message\TemplateRenderer\StringTemplateRenderer; +use Symfony\Component\EventDispatcher\EventDispatcher; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$eventDispatcher = new EventDispatcher(); +$rendererRegistry = new ChainTemplateRenderer([ + new StringTemplateRenderer(), +]); +$templateListener = new TemplateRendererListener($rendererRegistry); +$eventDispatcher->addSubscriber($templateListener); + +$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client(), eventDispatcher: $eventDispatcher); + +echo "Example 1: SystemMessage with template\n"; +echo "=======================================\n\n"; + +$template = Template::string('You are a {domain} expert assistant.'); +$messages = new MessageBag( + Message::forSystem($template), + Message::ofUser('What is PHP?') +); + +$result = $platform->invoke('gpt-4o-mini', $messages, [ + 'template_vars' => ['domain' => 'programming'], +]); + +echo "SystemMessage template: You are a {domain} expert assistant.\n"; +echo "Variables: ['domain' => 'programming']\n"; +echo 'Response: '.$result->asText()."\n\n"; + +echo "Example 2: UserMessage with template\n"; +echo "=====================================\n\n"; + +$messages = new MessageBag( + Message::forSystem('You are a helpful assistant.'), + Message::ofUser(Template::string('Tell me about {topic}')) +); + +$result = $platform->invoke('gpt-4o-mini', $messages, [ + 'template_vars' => ['topic' => 'PHP'], +]); + +echo "UserMessage template: Tell me about {topic}\n"; +echo "Variables: ['topic' => 'PHP']\n"; +echo 'Response: '.$result->asText()."\n\n"; + +echo "Example 3: Multiple messages with templates\n"; +echo "============================================\n\n"; + +$systemTemplate = Template::string('You are a {domain} assistant.'); +$userTemplate = Template::string('Calculate {operation}'); + +$messages = new MessageBag( + Message::forSystem($systemTemplate), + Message::ofUser($userTemplate) +); + +$result = $platform->invoke('gpt-4o-mini', $messages, [ + 'template_vars' => [ + 'domain' => 'math', + 'operation' => '2 + 2', + ], +]); + +echo "System template: You are a {domain} assistant.\n"; +echo "User template: Calculate {operation}\n"; +echo "Variables: ['domain' => 'math', 'operation' => '2 + 2']\n"; +echo 'Response: '.$result->asText()."\n\n"; + +echo "Example 4: UserMessage with mixed content\n"; +echo "==========================================\n\n"; + +$messages = new MessageBag( + Message::forSystem('You are a helpful assistant.'), + Message::ofUser('I need help with', Template::string(' {task}')) +); + +$result = $platform->invoke('gpt-4o-mini', $messages, [ + 'template_vars' => ['task' => 'debugging'], +]); + +echo "UserMessage: 'Plain text' + Template('{task}')\n"; +echo "Variables: ['task' => 'debugging']\n"; +echo 'Response: '.$result->asText()."\n"; diff --git a/src/ai-bundle/config/services.php b/src/ai-bundle/config/services.php index 1baacccc9..88a89b142 100644 --- a/src/ai-bundle/config/services.php +++ b/src/ai-bundle/config/services.php @@ -62,6 +62,10 @@ use Symfony\AI\Platform\Contract; use Symfony\AI\Platform\Contract\JsonSchema\DescriptionParser; use Symfony\AI\Platform\Contract\JsonSchema\Factory as SchemaFactory; +use Symfony\AI\Platform\EventListener\TemplateRendererListener; +use Symfony\AI\Platform\Message\TemplateRenderer\ChainTemplateRenderer; +use Symfony\AI\Platform\Message\TemplateRenderer\ExpressionLanguageTemplateRenderer; +use Symfony\AI\Platform\Message\TemplateRenderer\StringTemplateRenderer; use Symfony\AI\Platform\Serializer\StructuredOutputSerializer; use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber; use Symfony\AI\Platform\StructuredOutput\ResponseFormatFactory; @@ -69,6 +73,7 @@ use Symfony\AI\Store\Command\DropStoreCommand; use Symfony\AI\Store\Command\IndexCommand; use Symfony\AI\Store\Command\SetupStoreCommand; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; return static function (ContainerConfigurator $container): void { $container->services() @@ -111,6 +116,30 @@ ->set('ai.platform.model_catalog.vertexai.gemini', VertexAiModelCatalog::class) ->set('ai.platform.model_catalog.voyage', VoyageModelCatalog::class) + // message templates + ->set('ai.platform.template_renderer.string', StringTemplateRenderer::class) + ->tag('ai.platform.template_renderer'); + + if (class_exists(ExpressionLanguage::class)) { + $container->services() + ->set('ai.platform.template_renderer.expression', ExpressionLanguageTemplateRenderer::class) + ->args([ + service('expression_language'), + ]) + ->tag('ai.platform.template_renderer'); + } + + $container->services() + ->set('ai.platform.template_renderer_registry', ChainTemplateRenderer::class) + ->args([ + tagged_iterator('ai.platform.template_renderer'), + ]) + ->set('ai.platform.template_renderer_listener', TemplateRendererListener::class) + ->args([ + service('ai.platform.template_renderer_registry'), + ]) + ->tag('kernel.event_subscriber') + // structured output ->set('ai.agent.response_format_factory', ResponseFormatFactory::class) ->args([ diff --git a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php index 096aae43b..fefb29f5b 100644 --- a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php +++ b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php @@ -28,6 +28,10 @@ use Symfony\AI\Chat\MessageStoreInterface; use Symfony\AI\Platform\Bridge\Ollama\OllamaApiCatalog; use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\EventListener\TemplateRendererListener; +use Symfony\AI\Platform\Message\TemplateRenderer\ChainTemplateRenderer; +use Symfony\AI\Platform\Message\TemplateRenderer\ExpressionLanguageTemplateRenderer; +use Symfony\AI\Platform\Message\TemplateRenderer\StringTemplateRenderer; use Symfony\AI\Platform\Model; use Symfony\AI\Store\Bridge\Azure\SearchStore as AzureStore; use Symfony\AI\Store\Bridge\ChromaDb\Store as ChromaDbStore; @@ -54,6 +58,7 @@ use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\Translation\TranslatableMessage; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -4410,6 +4415,46 @@ public function testModelConfigurationIsIgnoredForUnknownPlatform() $this->assertSame([], $definition->getArguments()); } + public function testTemplateRendererServicesAreRegistered() + { + $container = $this->buildContainer([ + 'ai' => [ + 'platform' => [ + 'anthropic' => [ + 'api_key' => 'test_key', + ], + ], + ], + ]); + + // Verify string template renderer is registered + $this->assertTrue($container->hasDefinition('ai.platform.template_renderer.string')); + $stringRendererDefinition = $container->getDefinition('ai.platform.template_renderer.string'); + $this->assertSame(StringTemplateRenderer::class, $stringRendererDefinition->getClass()); + $this->assertTrue($stringRendererDefinition->hasTag('ai.platform.template_renderer')); + + // Verify expression template renderer is registered only when ExpressionLanguage is available + if (class_exists(ExpressionLanguage::class)) { + $this->assertTrue($container->hasDefinition('ai.platform.template_renderer.expression')); + $expressionRendererDefinition = $container->getDefinition('ai.platform.template_renderer.expression'); + $this->assertSame(ExpressionLanguageTemplateRenderer::class, $expressionRendererDefinition->getClass()); + $this->assertTrue($expressionRendererDefinition->hasTag('ai.platform.template_renderer')); + } else { + $this->assertFalse($container->hasDefinition('ai.platform.template_renderer.expression')); + } + + // Verify template renderer registry is registered + $this->assertTrue($container->hasDefinition('ai.platform.template_renderer_registry')); + $registryDefinition = $container->getDefinition('ai.platform.template_renderer_registry'); + $this->assertSame(ChainTemplateRenderer::class, $registryDefinition->getClass()); + + // Verify template renderer listener is registered as event subscriber + $this->assertTrue($container->hasDefinition('ai.platform.template_renderer_listener')); + $listenerDefinition = $container->getDefinition('ai.platform.template_renderer_listener'); + $this->assertSame(TemplateRendererListener::class, $listenerDefinition->getClass()); + $this->assertTrue($listenerDefinition->hasTag('kernel.event_subscriber')); + } + private function buildContainer(array $configuration): ContainerBuilder { $container = new ContainerBuilder(); diff --git a/src/platform/AGENTS.md b/src/platform/AGENTS.md index 72a057a75..38232a889 100644 --- a/src/platform/AGENTS.md +++ b/src/platform/AGENTS.md @@ -13,13 +13,15 @@ Unified abstraction for AI platforms (OpenAI, Anthropic, Azure, Gemini, VertexAI - **Model**: AI models with provider-specific configurations - **Contract**: Abstract contracts for AI capabilities (chat, embedding, speech) - **Message**: Message system for AI interactions +- **Template**: Message templating with pluggable rendering strategies - **Tool**: Function calling capabilities - **Bridge**: Provider-specific implementations ### Key Directories - `src/Bridge/`: Provider implementations - `src/Contract/`: Abstract contracts and interfaces -- `src/Message/`: Message handling system +- `src/Message/`: Message handling system with Template support +- `src/Message/TemplateRenderer/`: Template rendering strategies - `src/Tool/`: Function calling and tool definitions - `src/Result/`: Result types and converters - `src/Exception/`: Platform-specific exceptions @@ -54,6 +56,43 @@ composer install composer update ``` +## Usage Patterns + +### Message Templates + +Templates support variable substitution with type-based rendering. SystemMessage and UserMessage support templates. + +```php +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Message\Template; + +// SystemMessage with template +$template = Template::string('You are a {role} assistant.'); +$message = Message::forSystem($template); + +// UserMessage with template +$message = Message::ofUser(Template::string('Calculate {operation}')); + +// Multiple messages with templates +$messages = new MessageBag( + Message::forSystem(Template::string('You are a {role} assistant.')), + Message::ofUser(Template::string('Calculate {operation}')) +); + +$result = $platform->invoke('gpt-4o-mini', $messages, [ + 'template_vars' => [ + 'role' => 'helpful', + 'operation' => '2 + 2', + ], +]); + +// Expression template (requires symfony/expression-language) +$template = Template::expression('price * quantity'); +``` + +Rendering happens externally during `Platform.invoke()` when `template_vars` option is provided. + ## Development Notes - PHPUnit 11+ with strict configuration @@ -61,4 +100,6 @@ composer update - MockHttpClient pattern preferred - Follows Symfony coding standards - Bridge pattern for provider implementations -- Consistent contract interfaces across providers \ No newline at end of file +- Consistent contract interfaces across providers +- Template system uses type-based rendering (not renderer injection) +- Template rendering via TemplateRendererListener during invocation \ No newline at end of file diff --git a/src/platform/CLAUDE.md b/src/platform/CLAUDE.md index a1d233c4f..7a5ced825 100644 --- a/src/platform/CLAUDE.md +++ b/src/platform/CLAUDE.md @@ -44,16 +44,19 @@ composer update - **Model**: Represents AI models with provider-specific configurations - **Contract**: Abstract contracts for different AI capabilities (chat, embedding, speech, etc.) - **Message**: Message system for AI interactions +- **Template**: Message templating with type-based rendering strategies - **Tool**: Function calling capabilities - **Bridge**: Provider-specific implementations (OpenAI, Anthropic, etc.) ### Key Directories - `src/Bridge/`: Provider-specific implementations -- `src/Contract/`: Abstract contracts and interfaces -- `src/Message/`: Message handling system +- `src/Contract/`: Abstract contracts and interfaces +- `src/Message/`: Message handling system with Template support +- `src/Message/TemplateRenderer/`: Template rendering strategies - `src/Tool/`: Function calling and tool definitions - `src/Result/`: Result types and converters - `src/Exception/`: Platform-specific exceptions +- `src/EventListener/`: Event listeners (including TemplateRendererListener) ### Provider Support The component supports multiple AI providers through Bridge implementations: @@ -66,9 +69,53 @@ The component supports multiple AI providers through Bridge implementations: - Ollama - And many others (see composer.json keywords) +## Usage Examples + +### Message Templates + +Templates support variable substitution with type-based rendering. SystemMessage and UserMessage support templates: + +```php +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Message\Template; + +// SystemMessage with template +$template = Template::string('You are a {role} assistant.'); +$message = Message::forSystem($template); + +// UserMessage with template +$message = Message::ofUser(Template::string('Calculate {operation}')); + +// UserMessage with mixed content (text and template) +$message = Message::ofUser( + 'Plain text', + Template::string('and {dynamic} content') +); + +// Multiple messages +$messages = new MessageBag( + Message::forSystem(Template::string('You are a {role} assistant.')), + Message::ofUser(Template::string('Calculate {operation}')) +); + +$result = $platform->invoke('gpt-4o-mini', $messages, [ + 'template_vars' => [ + 'role' => 'helpful', + 'operation' => '2 + 2', + ], +]); + +// Expression template (requires symfony/expression-language) +$template = Template::expression('price * quantity'); +``` + +Templates are rendered during `Platform.invoke()` when `template_vars` option is provided. + ## Testing Architecture - Uses PHPUnit 11+ with strict configuration - Test fixtures located in `../../fixtures` for multi-modal content - Mock HTTP client pattern preferred over response mocking -- Component follows Symfony coding standards \ No newline at end of file +- Component follows Symfony coding standards +- Template tests cover all renderer types and integration scenarios diff --git a/src/platform/composer.json b/src/platform/composer.json index 8b5d3217d..90cefef4e 100644 --- a/src/platform/composer.json +++ b/src/platform/composer.json @@ -69,6 +69,7 @@ "symfony/cache": "^7.3|^8.0", "symfony/console": "^7.3|^8.0", "symfony/dotenv": "^7.3|^8.0", + "symfony/expression-language": "^7.3|^8.0", "symfony/finder": "^7.3|^8.0", "symfony/process": "^7.3|^8.0", "symfony/var-dumper": "^7.3|^8.0" diff --git a/src/platform/src/EventListener/TemplateRendererListener.php b/src/platform/src/EventListener/TemplateRendererListener.php new file mode 100644 index 000000000..fe66414ef --- /dev/null +++ b/src/platform/src/EventListener/TemplateRendererListener.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\EventListener; + +use Symfony\AI\Platform\Event\InvocationEvent; +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Message\MessageInterface; +use Symfony\AI\Platform\Message\SystemMessage; +use Symfony\AI\Platform\Message\Template; +use Symfony\AI\Platform\Message\TemplateRenderer\ChainTemplateRenderer; +use Symfony\AI\Platform\Message\UserMessage; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Renders message templates when template_vars option is provided. + * + * @author Johannes Wachter + */ +final readonly class TemplateRendererListener implements EventSubscriberInterface +{ + public function __construct( + private ChainTemplateRenderer $rendererRegistry, + ) { + } + + public static function getSubscribedEvents(): array + { + return [ + InvocationEvent::class => '__invoke', + ]; + } + + public function __invoke(InvocationEvent $event): void + { + $options = $event->getOptions(); + if (!isset($options['template_vars']) || !\is_array($options['template_vars'])) { + return; + } + + $input = $event->getInput(); + if (!$input instanceof MessageBag) { + return; + } + + $templateVars = $options['template_vars']; + $renderedMessages = []; + + foreach ($input->getMessages() as $message) { + $renderedMessages[] = $this->renderMessage($message, $templateVars); + } + + $event->setInput(new MessageBag(...$renderedMessages)); + + unset($options['template_vars']); + $event->setOptions($options); + } + + /** + * @param array $templateVars + */ + private function renderMessage(MessageInterface $message, array $templateVars): MessageInterface + { + if ($message instanceof SystemMessage) { + $content = $message->getContent(); + if ($content instanceof Template) { + $renderedContent = $this->rendererRegistry->render($content, $templateVars); + + return new SystemMessage($renderedContent); + } + } + + if ($message instanceof UserMessage) { + $hasTemplate = false; + $renderedContent = []; + + foreach ($message->getContent() as $content) { + if ($content instanceof Template) { + $hasTemplate = true; + $renderedText = $this->rendererRegistry->render($content, $templateVars); + $renderedContent[] = new Text($renderedText); + } else { + $renderedContent[] = $content; + } + } + + if ($hasTemplate) { + return new UserMessage(...$renderedContent); + } + } + + return $message; + } +} diff --git a/src/platform/src/Message/AssistantMessage.php b/src/platform/src/Message/AssistantMessage.php index 3bb7370b2..3f24762e0 100644 --- a/src/platform/src/Message/AssistantMessage.php +++ b/src/platform/src/Message/AssistantMessage.php @@ -30,7 +30,7 @@ final class AssistantMessage implements MessageInterface * @param ?ToolCall[] $toolCalls */ public function __construct( - private ?string $content = null, + private string|Template|null $content = null, private ?array $toolCalls = null, ) { $this->id = Uuid::v7(); @@ -59,7 +59,7 @@ public function getToolCalls(): ?array return $this->toolCalls; } - public function getContent(): ?string + public function getContent(): string|Template|null { return $this->content; } diff --git a/src/platform/src/Message/Message.php b/src/platform/src/Message/Message.php index eb384ea84..fe324b8a3 100644 --- a/src/platform/src/Message/Message.php +++ b/src/platform/src/Message/Message.php @@ -26,15 +26,19 @@ private function __construct() { } - public static function forSystem(\Stringable|string $content): SystemMessage + public static function forSystem(\Stringable|string|Template $content): SystemMessage { + if ($content instanceof Template) { + return new SystemMessage($content); + } + return new SystemMessage($content instanceof \Stringable ? (string) $content : $content); } /** * @param ?ToolCall[] $toolCalls */ - public static function ofAssistant(?string $content = null, ?array $toolCalls = null): AssistantMessage + public static function ofAssistant(string|Template|null $content = null, ?array $toolCalls = null): AssistantMessage { return new AssistantMessage($content, $toolCalls); } @@ -42,7 +46,11 @@ public static function ofAssistant(?string $content = null, ?array $toolCalls = public static function ofUser(\Stringable|string|ContentInterface ...$content): UserMessage { $content = array_map( - static fn (\Stringable|string|ContentInterface $entry) => $entry instanceof ContentInterface ? $entry : (\is_string($entry) ? new Text($entry) : new Text((string) $entry)), + static fn (\Stringable|string|ContentInterface $entry) => match (true) { + $entry instanceof ContentInterface => $entry, + \is_string($entry) => new Text($entry), + default => new Text((string) $entry), + }, $content, ); diff --git a/src/platform/src/Message/MessageInterface.php b/src/platform/src/Message/MessageInterface.php index 7ca9229c6..a7e2dedcf 100644 --- a/src/platform/src/Message/MessageInterface.php +++ b/src/platform/src/Message/MessageInterface.php @@ -26,9 +26,9 @@ public function getRole(): Role; public function getId(): AbstractUid&TimeBasedUidInterface; /** - * @return string|ContentInterface[]|null + * @return string|Template|ContentInterface[]|null */ - public function getContent(): string|array|null; + public function getContent(): string|Template|array|null; public function getMetadata(): Metadata; } diff --git a/src/platform/src/Message/SystemMessage.php b/src/platform/src/Message/SystemMessage.php index d0650773a..efcb9833f 100644 --- a/src/platform/src/Message/SystemMessage.php +++ b/src/platform/src/Message/SystemMessage.php @@ -26,7 +26,7 @@ final class SystemMessage implements MessageInterface private readonly AbstractUid&TimeBasedUidInterface $id; public function __construct( - private readonly string $content, + private readonly string|Template $content, ) { $this->id = Uuid::v7(); } @@ -41,7 +41,7 @@ public function getId(): AbstractUid&TimeBasedUidInterface return $this->id; } - public function getContent(): string + public function getContent(): string|Template { return $this->content; } diff --git a/src/platform/src/Message/Template.php b/src/platform/src/Message/Template.php new file mode 100644 index 000000000..08a4f1867 --- /dev/null +++ b/src/platform/src/Message/Template.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Message; + +use Symfony\AI\Platform\Message\Content\ContentInterface; + +/** + * Message template with type-based rendering strategy. + * + * Supports variable substitution using different rendering types. + * Rendering happens externally during message serialization when template_vars are provided. + * + * @author Johannes Wachter + */ +final readonly class Template implements \Stringable, ContentInterface +{ + public function __construct( + private string $template, + private string $type, + ) { + } + + public function __toString(): string + { + return $this->template; + } + + public function getTemplate(): string + { + return $this->template; + } + + public function getType(): string + { + return $this->type; + } + + public static function string(string $template): self + { + return new self($template, 'string'); + } + + public static function expression(string $template): self + { + return new self($template, 'expression'); + } +} diff --git a/src/platform/src/Message/TemplateRenderer/ChainTemplateRenderer.php b/src/platform/src/Message/TemplateRenderer/ChainTemplateRenderer.php new file mode 100644 index 000000000..72db0cfa1 --- /dev/null +++ b/src/platform/src/Message/TemplateRenderer/ChainTemplateRenderer.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Message\TemplateRenderer; + +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\AI\Platform\Message\Template; + +/** + * Composite renderer that chains multiple renderers. + * + * Implements chain of responsibility pattern - tries each renderer until one supports the type. + * + * @author Johannes Wachter + */ +final readonly class ChainTemplateRenderer implements TemplateRendererInterface +{ + /** + * @var TemplateRendererInterface[] + */ + private array $renderers; + + /** + * @param iterable $renderers + */ + public function __construct(iterable $renderers) + { + $this->renderers = $renderers instanceof \Traversable ? iterator_to_array($renderers) : $renderers; + } + + public function supports(string $type): bool + { + foreach ($this->renderers as $renderer) { + if ($renderer->supports($type)) { + return true; + } + } + + return false; + } + + public function render(Template $template, array $variables): string + { + foreach ($this->renderers as $renderer) { + if ($renderer->supports($template->getType())) { + return $renderer->render($template, $variables); + } + } + + throw new InvalidArgumentException(\sprintf('No renderer found for template type "%s".', $template->getType())); + } +} diff --git a/src/platform/src/Message/TemplateRenderer/ExpressionLanguageTemplateRenderer.php b/src/platform/src/Message/TemplateRenderer/ExpressionLanguageTemplateRenderer.php new file mode 100644 index 000000000..4a79c8143 --- /dev/null +++ b/src/platform/src/Message/TemplateRenderer/ExpressionLanguageTemplateRenderer.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Message\TemplateRenderer; + +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\AI\Platform\Message\Template; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; + +/** + * @author Johannes Wachter + */ +final readonly class ExpressionLanguageTemplateRenderer implements TemplateRendererInterface +{ + private ExpressionLanguage $expressionLanguage; + + public function __construct(?ExpressionLanguage $expressionLanguage = null) + { + if (!class_exists(ExpressionLanguage::class)) { + throw new InvalidArgumentException('ExpressionTemplateRenderer requires "symfony/expression-language" package.'); + } + + $this->expressionLanguage = $expressionLanguage ?? new ExpressionLanguage(); + } + + public function supports(string $type): bool + { + return 'expression' === $type; + } + + public function render(Template $template, array $variables): string + { + try { + return (string) $this->expressionLanguage->evaluate( + $template->getTemplate(), + $variables + ); + } catch (\Throwable $e) { + throw new InvalidArgumentException(\sprintf('Failed to render expression template: %s', $e->getMessage()), previous: $e); + } + } +} diff --git a/src/platform/src/Message/TemplateRenderer/StringTemplateRenderer.php b/src/platform/src/Message/TemplateRenderer/StringTemplateRenderer.php new file mode 100644 index 000000000..0c282f42c --- /dev/null +++ b/src/platform/src/Message/TemplateRenderer/StringTemplateRenderer.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Message\TemplateRenderer; + +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\AI\Platform\Message\Template; + +/** + * Simple string replacement renderer. + * + * Replaces {variable} placeholders with values from the provided array. + * Has zero external dependencies. + * + * @author Johannes Wachter + */ +final readonly class StringTemplateRenderer implements TemplateRendererInterface +{ + public function supports(string $type): bool + { + return 'string' === $type; + } + + public function render(Template $template, array $variables): string + { + $result = $template->getTemplate(); + + foreach ($variables as $key => $value) { + if (!\is_string($key)) { + throw new InvalidArgumentException(\sprintf('Template variable keys must be strings, "%s" given.', get_debug_type($key))); + } + + if (!\is_string($value) && !is_numeric($value) && !$value instanceof \Stringable) { + throw new InvalidArgumentException(\sprintf('Template variable "%s" must be string, numeric or Stringable, "%s" given.', $key, get_debug_type($value))); + } + + $result = str_replace('{'.$key.'}', (string) $value, $result); + } + + return $result; + } +} diff --git a/src/platform/src/Message/TemplateRenderer/TemplateRendererInterface.php b/src/platform/src/Message/TemplateRenderer/TemplateRendererInterface.php new file mode 100644 index 000000000..ecee97593 --- /dev/null +++ b/src/platform/src/Message/TemplateRenderer/TemplateRendererInterface.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Message\TemplateRenderer; + +use Symfony\AI\Platform\Message\Template; + +/** + * @author Johannes Wachter + */ +interface TemplateRendererInterface +{ + public function supports(string $type): bool; + + /** + * @param array $variables + */ + public function render(Template $template, array $variables): string; +} diff --git a/src/platform/tests/EventListener/TemplateRendererListenerTest.php b/src/platform/tests/EventListener/TemplateRendererListenerTest.php new file mode 100644 index 000000000..369fd02ea --- /dev/null +++ b/src/platform/tests/EventListener/TemplateRendererListenerTest.php @@ -0,0 +1,196 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\EventListener; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Event\InvocationEvent; +use Symfony\AI\Platform\EventListener\TemplateRendererListener; +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Message\Template; +use Symfony\AI\Platform\Message\TemplateRenderer\ChainTemplateRenderer; +use Symfony\AI\Platform\Message\TemplateRenderer\StringTemplateRenderer; +use Symfony\AI\Platform\Model; + +final class TemplateRendererListenerTest extends TestCase +{ + private TemplateRendererListener $listener; + private Model $model; + + protected function setUp(): void + { + $registry = new ChainTemplateRenderer([ + new StringTemplateRenderer(), + ]); + + $this->listener = new TemplateRendererListener($registry); + $this->model = new Model('gpt-4o'); + } + + public function testRendersTemplateWhenTemplateVarsProvided(): void + { + $template = Template::string('Hello {name}!'); + $messageBag = new MessageBag(Message::forSystem($template)); + + $event = new InvocationEvent($this->model, $messageBag, [ + 'template_vars' => ['name' => 'World'], + ]); + + ($this->listener)($event); + + $input = $event->getInput(); + $this->assertInstanceOf(MessageBag::class, $input); + $messages = $input->getMessages(); + $this->assertCount(1, $messages); + $this->assertSame('Hello World!', $messages[0]->getContent()); + } + + public function testRemovesTemplateVarsFromOptions(): void + { + $template = Template::string('Hello {name}!'); + $messageBag = new MessageBag(Message::forSystem($template)); + + $event = new InvocationEvent($this->model, $messageBag, [ + 'template_vars' => ['name' => 'World'], + 'other_option' => 'value', + ]); + + ($this->listener)($event); + + $options = $event->getOptions(); + $this->assertArrayNotHasKey('template_vars', $options); + $this->assertArrayHasKey('other_option', $options); + } + + public function testDoesNothingWhenTemplateVarsNotProvided(): void + { + $template = Template::string('Hello {name}!'); + $messageBag = new MessageBag(Message::forSystem($template)); + + $event = new InvocationEvent($this->model, $messageBag, []); + + ($this->listener)($event); + + $input = $event->getInput(); + $this->assertInstanceOf(MessageBag::class, $input); + $messages = $input->getMessages(); + $this->assertCount(1, $messages); + $this->assertInstanceOf(Template::class, $messages[0]->getContent()); + } + + public function testDoesNothingWhenInputIsNotMessageBag(): void + { + $event = new InvocationEvent($this->model, 'string input', [ + 'template_vars' => ['name' => 'World'], + ]); + + ($this->listener)($event); + + $this->assertSame('string input', $event->getInput()); + } + + public function testRendersMultipleMessages(): void + { + $template1 = Template::string('System: {role}'); + $template2 = Template::string('User: {query}'); + + $messageBag = new MessageBag( + Message::forSystem($template1), + Message::forSystem($template2) + ); + + $event = new InvocationEvent($this->model, $messageBag, [ + 'template_vars' => [ + 'role' => 'assistant', + 'query' => 'help', + ], + ]); + + ($this->listener)($event); + + $input = $event->getInput(); + $this->assertInstanceOf(MessageBag::class, $input); + $messages = $input->getMessages(); + $this->assertCount(2, $messages); + $this->assertSame('System: assistant', $messages[0]->getContent()); + $this->assertSame('User: help', $messages[1]->getContent()); + } + + public function testDoesNotRenderNonTemplateMessages(): void + { + $messageBag = new MessageBag( + Message::forSystem('Plain string'), + Message::forSystem(Template::string('Hello {name}!')) + ); + + $event = new InvocationEvent($this->model, $messageBag, [ + 'template_vars' => ['name' => 'World'], + ]); + + ($this->listener)($event); + + $input = $event->getInput(); + $this->assertInstanceOf(MessageBag::class, $input); + $messages = $input->getMessages(); + $this->assertCount(2, $messages); + $this->assertSame('Plain string', $messages[0]->getContent()); + $this->assertSame('Hello World!', $messages[1]->getContent()); + } + + public function testRendersUserMessageTemplate(): void + { + $template = Template::string('Question: {query}'); + $messageBag = new MessageBag(Message::ofUser($template)); + + $event = new InvocationEvent($this->model, $messageBag, [ + 'template_vars' => ['query' => 'What is AI?'], + ]); + + ($this->listener)($event); + + $input = $event->getInput(); + $this->assertInstanceOf(MessageBag::class, $input); + $messages = $input->getMessages(); + $this->assertCount(1, $messages); + + $content = $messages[0]->getContent(); + $this->assertIsArray($content); + $this->assertCount(1, $content); + $this->assertInstanceOf(Text::class, $content[0]); + $this->assertSame('Question: What is AI?', $content[0]->getText()); + } + + public function testRendersUserMessageWithMixedContent(): void + { + $messageBag = new MessageBag( + Message::ofUser('Plain text', Template::string(' and {templated}')) + ); + + $event = new InvocationEvent($this->model, $messageBag, [ + 'template_vars' => ['templated' => 'dynamic content'], + ]); + + ($this->listener)($event); + + $input = $event->getInput(); + $this->assertInstanceOf(MessageBag::class, $input); + $messages = $input->getMessages(); + $content = $messages[0]->getContent(); + + $this->assertCount(2, $content); + $this->assertInstanceOf(Text::class, $content[0]); + $this->assertSame('Plain text', $content[0]->getText()); + $this->assertInstanceOf(Text::class, $content[1]); + $this->assertSame(' and dynamic content', $content[1]->getText()); + } +} diff --git a/src/platform/tests/Message/TemplateRenderer/ChainTemplateRendererTest.php b/src/platform/tests/Message/TemplateRenderer/ChainTemplateRendererTest.php new file mode 100644 index 000000000..ee571ad5c --- /dev/null +++ b/src/platform/tests/Message/TemplateRenderer/ChainTemplateRendererTest.php @@ -0,0 +1,123 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Message\TemplateRenderer; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\AI\Platform\Message\Template; +use Symfony\AI\Platform\Message\TemplateRenderer\ChainTemplateRenderer; +use Symfony\AI\Platform\Message\TemplateRenderer\StringTemplateRenderer; +use Symfony\AI\Platform\Message\TemplateRenderer\TemplateRendererInterface; + +final class ChainTemplateRendererTest extends TestCase +{ + public function testRenderWithSupportedType(): void + { + $registry = new ChainTemplateRenderer([ + new StringTemplateRenderer(), + ]); + + $template = Template::string('Hello {name}!'); + + $result = $registry->render($template, ['name' => 'World']); + + $this->assertSame('Hello World!', $result); + } + + public function testRenderSelectsCorrectRenderer(): void + { + $renderer1 = new class implements TemplateRendererInterface { + public function supports(string $type): bool + { + return false; + } + + public function render(Template $template, array $variables): string + { + return 'should not be called'; + } + }; + + $renderer2 = new StringTemplateRenderer(); + + $registry = new ChainTemplateRenderer([$renderer1, $renderer2]); + + $template = Template::string('Hello {name}!'); + + $result = $registry->render($template, ['name' => 'World']); + + $this->assertSame('Hello World!', $result); + } + + public function testThrowsExceptionForUnsupportedType(): void + { + $registry = new ChainTemplateRenderer([ + new StringTemplateRenderer(), + ]); + + $template = new Template('test', 'unsupported'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No renderer found for template type "unsupported"'); + + $registry->render($template, []); + } + + public function testAcceptsIterableOfRenderers(): void + { + $registry = new ChainTemplateRenderer(new \ArrayIterator([ + new StringTemplateRenderer(), + ])); + + $template = Template::string('Hello {name}!'); + + $result = $registry->render($template, ['name' => 'World']); + + $this->assertSame('Hello World!', $result); + } + + public function testImplementsRendererInterface(): void + { + $registry = new ChainTemplateRenderer([ + new StringTemplateRenderer(), + ]); + + $this->assertInstanceOf(TemplateRendererInterface::class, $registry); + } + + public function testSupportsMethod(): void + { + $registry = new ChainTemplateRenderer([ + new StringTemplateRenderer(), + ]); + + $this->assertTrue($registry->supports('string')); + $this->assertFalse($registry->supports('unsupported')); + } + + public function testCanBeNestedAsComposite(): void + { + $innerRegistry = new ChainTemplateRenderer([ + new StringTemplateRenderer(), + ]); + + $outerRegistry = new ChainTemplateRenderer([ + $innerRegistry, + ]); + + $template = Template::string('Hello {name}!'); + + $result = $outerRegistry->render($template, ['name' => 'World']); + + $this->assertSame('Hello World!', $result); + } +} diff --git a/src/platform/tests/Message/TemplateRenderer/ExpressionLanguageTemplateRendererTest.php b/src/platform/tests/Message/TemplateRenderer/ExpressionLanguageTemplateRendererTest.php new file mode 100644 index 000000000..732751f8f --- /dev/null +++ b/src/platform/tests/Message/TemplateRenderer/ExpressionLanguageTemplateRendererTest.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Message\TemplateRenderer; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\AI\Platform\Message\Template; +use Symfony\AI\Platform\Message\TemplateRenderer\ExpressionLanguageTemplateRenderer; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; + +final class ExpressionLanguageTemplateRendererTest extends TestCase +{ + private ExpressionLanguageTemplateRenderer $renderer; + + protected function setUp(): void + { + if (!class_exists(ExpressionLanguage::class)) { + $this->markTestSkipped('symfony/expression-language is not installed'); + } + + $this->renderer = new ExpressionLanguageTemplateRenderer(); + } + + public function testSupportsExpressionType(): void + { + $this->assertTrue($this->renderer->supports('expression')); + $this->assertFalse($this->renderer->supports('string')); + $this->assertFalse($this->renderer->supports('twig')); + } + + public function testRenderSimpleExpression(): void + { + $template = Template::expression('price * quantity'); + + $result = $this->renderer->render($template, [ + 'price' => 10, + 'quantity' => 5, + ]); + + $this->assertSame('50', $result); + } + + public function testRenderComplexExpression(): void + { + $template = Template::expression('(price * quantity) + tax'); + + $result = $this->renderer->render($template, [ + 'price' => 10, + 'quantity' => 5, + 'tax' => 5, + ]); + + $this->assertSame('55', $result); + } + + public function testRenderStringConcatenation(): void + { + $template = Template::expression('greeting ~ " " ~ name'); + + $result = $this->renderer->render($template, [ + 'greeting' => 'Hello', + 'name' => 'World', + ]); + + $this->assertSame('Hello World', $result); + } + + public function testThrowsExceptionForInvalidExpression(): void + { + $template = Template::expression('invalid expression syntax {'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Failed to render expression template'); + + $this->renderer->render($template, []); + } + + public function testConstructorThrowsExceptionWhenExpressionLanguageNotAvailable(): void + { + if (class_exists(ExpressionLanguage::class)) { + $this->markTestSkipped('This test requires ExpressionLanguage to not be available'); + } + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('ExpressionTemplateRenderer requires "symfony/expression-language" package'); + + new ExpressionLanguageTemplateRenderer(); + } +} diff --git a/src/platform/tests/Message/TemplateRenderer/StringTemplateRendererTest.php b/src/platform/tests/Message/TemplateRenderer/StringTemplateRendererTest.php new file mode 100644 index 000000000..615674e63 --- /dev/null +++ b/src/platform/tests/Message/TemplateRenderer/StringTemplateRendererTest.php @@ -0,0 +1,122 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Message\TemplateRenderer; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\AI\Platform\Message\Template; +use Symfony\AI\Platform\Message\TemplateRenderer\StringTemplateRenderer; + +final class StringTemplateRendererTest extends TestCase +{ + private StringTemplateRenderer $renderer; + + protected function setUp(): void + { + $this->renderer = new StringTemplateRenderer(); + } + + public function testSupportsStringType(): void + { + $this->assertTrue($this->renderer->supports('string')); + $this->assertFalse($this->renderer->supports('expression')); + $this->assertFalse($this->renderer->supports('twig')); + } + + public function testRenderSimpleVariable(): void + { + $template = Template::string('Hello {name}!'); + + $result = $this->renderer->render($template, ['name' => 'World']); + + $this->assertSame('Hello World!', $result); + } + + public function testRenderMultipleVariables(): void + { + $template = Template::string('{greeting} {name}!'); + + $result = $this->renderer->render($template, [ + 'greeting' => 'Hello', + 'name' => 'World', + ]); + + $this->assertSame('Hello World!', $result); + } + + public function testRenderNumericValue(): void + { + $template = Template::string('The answer is {answer}'); + + $result = $this->renderer->render($template, ['answer' => 42]); + + $this->assertSame('The answer is 42', $result); + } + + public function testRenderStringableValue(): void + { + $stringable = new class implements \Stringable { + public function __toString(): string + { + return 'stringable'; + } + }; + + $template = Template::string('Value: {value}'); + + $result = $this->renderer->render($template, ['value' => $stringable]); + + $this->assertSame('Value: stringable', $result); + } + + public function testRenderWithUnusedVariable(): void + { + $template = Template::string('Hello {name}!'); + + $result = $this->renderer->render($template, [ + 'name' => 'World', + 'unused' => 'value', + ]); + + $this->assertSame('Hello World!', $result); + } + + public function testRenderWithMissingVariable(): void + { + $template = Template::string('Hello {name}!'); + + $result = $this->renderer->render($template, []); + + $this->assertSame('Hello {name}!', $result); + } + + public function testThrowsExceptionForNonStringKey(): void + { + $template = Template::string('Hello {name}!'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Template variable keys must be strings'); + + /* @phpstan-ignore-next-line - Intentionally passing wrong type to test exception */ + $this->renderer->render($template, [0 => 'value']); + } + + public function testThrowsExceptionForInvalidValueType(): void + { + $template = Template::string('Hello {name}!'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Template variable "name" must be string, numeric or Stringable'); + + $this->renderer->render($template, ['name' => []]); + } +} diff --git a/src/platform/tests/Message/TemplateTest.php b/src/platform/tests/Message/TemplateTest.php new file mode 100644 index 000000000..9f0796fc0 --- /dev/null +++ b/src/platform/tests/Message/TemplateTest.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Message; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Message\Template; + +final class TemplateTest extends TestCase +{ + public function testConstructor(): void + { + $template = new Template('Hello {name}', 'string'); + + $this->assertSame('Hello {name}', $template->getTemplate()); + $this->assertSame('string', $template->getType()); + } + + public function testStringable(): void + { + $template = new Template('Hello {name}', 'string'); + + $this->assertSame('Hello {name}', (string) $template); + } + + public function testStringNamedConstructor(): void + { + $template = Template::string('Hello {name}'); + + $this->assertSame('Hello {name}', $template->getTemplate()); + $this->assertSame('string', $template->getType()); + } + + public function testExpressionNamedConstructor(): void + { + $template = Template::expression('Total: {price * quantity}'); + + $this->assertSame('Total: {price * quantity}', $template->getTemplate()); + $this->assertSame('expression', $template->getType()); + } +}