diff --git a/demo/config/reference.php b/demo/config/reference.php index f01ecae4f..65c259f48 100644 --- a/demo/config/reference.php +++ b/demo/config/reference.php @@ -1,6 +1,13 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Symfony\Component\DependencyInjection\Loader\Configurator; diff --git a/examples/platform/template/mixed-content.php b/examples/platform/template/mixed-content.php new file mode 100644 index 000000000..0e3221058 --- /dev/null +++ b/examples/platform/template/mixed-content.php @@ -0,0 +1,46 @@ + + * + * 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\StringTemplateRenderer; +use Symfony\AI\Platform\Message\TemplateRenderer\TemplateRendererRegistry; +use Symfony\Component\EventDispatcher\EventDispatcher; + +require_once dirname(__DIR__, 2).'/bootstrap.php'; + +$eventDispatcher = new EventDispatcher(); +$rendererRegistry = new TemplateRendererRegistry([ + new StringTemplateRenderer(), +]); +$templateListener = new TemplateRendererListener($rendererRegistry); +$eventDispatcher->addSubscriber($templateListener); + +$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client(), eventDispatcher: $eventDispatcher); + +echo "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/examples/platform/template/multiple-messages.php b/examples/platform/template/multiple-messages.php new file mode 100644 index 000000000..746ac083d --- /dev/null +++ b/examples/platform/template/multiple-messages.php @@ -0,0 +1,53 @@ + + * + * 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\StringTemplateRenderer; +use Symfony\AI\Platform\Message\TemplateRenderer\TemplateRendererRegistry; +use Symfony\Component\EventDispatcher\EventDispatcher; + +require_once dirname(__DIR__, 2).'/bootstrap.php'; + +$eventDispatcher = new EventDispatcher(); +$rendererRegistry = new TemplateRendererRegistry([ + new StringTemplateRenderer(), +]); +$templateListener = new TemplateRendererListener($rendererRegistry); +$eventDispatcher->addSubscriber($templateListener); + +$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client(), eventDispatcher: $eventDispatcher); + +echo "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"; diff --git a/examples/platform/template/system-message.php b/examples/platform/template/system-message.php new file mode 100644 index 000000000..02d882030 --- /dev/null +++ b/examples/platform/template/system-message.php @@ -0,0 +1,47 @@ + + * + * 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\StringTemplateRenderer; +use Symfony\AI\Platform\Message\TemplateRenderer\TemplateRendererRegistry; +use Symfony\Component\EventDispatcher\EventDispatcher; + +require_once dirname(__DIR__, 2).'/bootstrap.php'; + +$eventDispatcher = new EventDispatcher(); +$rendererRegistry = new TemplateRendererRegistry([ + new StringTemplateRenderer(), +]); +$templateListener = new TemplateRendererListener($rendererRegistry); +$eventDispatcher->addSubscriber($templateListener); + +$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client(), eventDispatcher: $eventDispatcher); + +echo "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"; diff --git a/examples/platform/template/user-message.php b/examples/platform/template/user-message.php new file mode 100644 index 000000000..4daf5cb64 --- /dev/null +++ b/examples/platform/template/user-message.php @@ -0,0 +1,46 @@ + + * + * 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\StringTemplateRenderer; +use Symfony\AI\Platform\Message\TemplateRenderer\TemplateRendererRegistry; +use Symfony\Component\EventDispatcher\EventDispatcher; + +require_once dirname(__DIR__, 2).'/bootstrap.php'; + +$eventDispatcher = new EventDispatcher(); +$rendererRegistry = new TemplateRendererRegistry([ + new StringTemplateRenderer(), +]); +$templateListener = new TemplateRendererListener($rendererRegistry); +$eventDispatcher->addSubscriber($templateListener); + +$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client(), eventDispatcher: $eventDispatcher); + +echo "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"; diff --git a/src/ai-bundle/config/services.php b/src/ai-bundle/config/services.php index a6d87cda6..f1b086844 100644 --- a/src/ai-bundle/config/services.php +++ b/src/ai-bundle/config/services.php @@ -64,6 +64,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\ExpressionLanguageTemplateRenderer; +use Symfony\AI\Platform\Message\TemplateRenderer\StringTemplateRenderer; +use Symfony\AI\Platform\Message\TemplateRenderer\TemplateRendererRegistry; use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface; use Symfony\AI\Platform\Serializer\StructuredOutputSerializer; use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber; @@ -73,6 +77,7 @@ use Symfony\AI\Store\Command\IndexCommand; use Symfony\AI\Store\Command\RetrieveCommand; use Symfony\AI\Store\Command\SetupStoreCommand; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; return static function (ContainerConfigurator $container): void { $container->services() @@ -119,6 +124,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', TemplateRendererRegistry::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 f543d749f..d1fc806df 100644 --- a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php +++ b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php @@ -33,6 +33,10 @@ use Symfony\AI\Platform\Bridge\ElevenLabs\PlatformFactory as ElevenLabsPlatformFactory; use Symfony\AI\Platform\Bridge\Ollama\OllamaApiCatalog; use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\EventListener\TemplateRendererListener; +use Symfony\AI\Platform\Message\TemplateRenderer\ExpressionLanguageTemplateRenderer; +use Symfony\AI\Platform\Message\TemplateRenderer\StringTemplateRenderer; +use Symfony\AI\Platform\Message\TemplateRenderer\TemplateRendererRegistry; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface; use Symfony\AI\Platform\PlatformInterface; @@ -6978,6 +6982,42 @@ 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 + $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')); + + // 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(TemplateRendererRegistry::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 18b7450f1..9489c67a7 100644 --- a/src/platform/composer.json +++ b/src/platform/composer.json @@ -73,6 +73,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..1eac4f6f0 --- /dev/null +++ b/src/platform/src/EventListener/TemplateRendererListener.php @@ -0,0 +1,113 @@ + + * + * 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\Exception\InvalidArgumentException; +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\TemplateRendererRegistryInterface; +use Symfony\AI\Platform\Message\UserMessage; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Renders message templates when template_vars option is provided. + * + * @author Johannes Wachter + */ +final class TemplateRendererListener implements EventSubscriberInterface +{ + public function __construct( + private readonly TemplateRendererRegistryInterface $rendererRegistry, + ) { + } + + public function __invoke(InvocationEvent $event): void + { + $options = $event->getOptions(); + if (!isset($options['template_vars'])) { + return; + } + + if (!\is_array($options['template_vars'])) { + throw new InvalidArgumentException('The "template_vars" option must be an array.'); + } + + $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); + } + + public static function getSubscribedEvents(): array + { + return [ + InvocationEvent::class => '__invoke', + ]; + } + + /** + * @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 + ->getRenderer($content->getType()) + ->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 + ->getRenderer($content->getType()) + ->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/Message.php b/src/platform/src/Message/Message.php index eb384ea84..fc3db2fd8 100644 --- a/src/platform/src/Message/Message.php +++ b/src/platform/src/Message/Message.php @@ -26,8 +26,12 @@ 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); } @@ -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..165ed23c7 --- /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 class Template implements \Stringable, ContentInterface +{ + public function __construct( + private readonly string $template, + private readonly 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/ExpressionLanguageTemplateRenderer.php b/src/platform/src/Message/TemplateRenderer/ExpressionLanguageTemplateRenderer.php new file mode 100644 index 000000000..f3521b9c8 --- /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 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..5c5ee0481 --- /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 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/src/Message/TemplateRenderer/TemplateRendererRegistry.php b/src/platform/src/Message/TemplateRenderer/TemplateRendererRegistry.php new file mode 100644 index 000000000..cf3443f3f --- /dev/null +++ b/src/platform/src/Message/TemplateRenderer/TemplateRendererRegistry.php @@ -0,0 +1,48 @@ + + * + * 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; + +/** + * Registry for managing template renderers. + * + * Provides access to template renderers based on template type. + * + * @author Johannes Wachter + */ +final class TemplateRendererRegistry implements TemplateRendererRegistryInterface +{ + /** + * @var TemplateRendererInterface[] + */ + private readonly array $renderers; + + /** + * @param iterable $renderers + */ + public function __construct(iterable $renderers) + { + $this->renderers = $renderers instanceof \Traversable ? iterator_to_array($renderers) : $renderers; + } + + public function getRenderer(string $type): TemplateRendererInterface + { + foreach ($this->renderers as $renderer) { + if ($renderer->supports($type)) { + return $renderer; + } + } + + throw new InvalidArgumentException(\sprintf('No renderer found for template type "%s".', $type)); + } +} diff --git a/src/platform/src/Message/TemplateRenderer/TemplateRendererRegistryInterface.php b/src/platform/src/Message/TemplateRenderer/TemplateRendererRegistryInterface.php new file mode 100644 index 000000000..60c2d233d --- /dev/null +++ b/src/platform/src/Message/TemplateRenderer/TemplateRendererRegistryInterface.php @@ -0,0 +1,29 @@ + + * + * 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; + +/** + * Registry for managing template renderers. + * + * Provides access to template renderers based on template type. + * + * @author Johannes Wachter + */ +interface TemplateRendererRegistryInterface +{ + /** + * @throws InvalidArgumentException If no renderer supports the type + */ + public function getRenderer(string $type): TemplateRendererInterface; +} diff --git a/src/platform/tests/EventListener/TemplateRendererListenerTest.php b/src/platform/tests/EventListener/TemplateRendererListenerTest.php new file mode 100644 index 000000000..743a221c9 --- /dev/null +++ b/src/platform/tests/EventListener/TemplateRendererListenerTest.php @@ -0,0 +1,212 @@ + + * + * 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\Exception\InvalidArgumentException; +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\StringTemplateRenderer; +use Symfony\AI\Platform\Message\TemplateRenderer\TemplateRendererRegistry; +use Symfony\AI\Platform\Model; + +final class TemplateRendererListenerTest extends TestCase +{ + private TemplateRendererListener $listener; + private Model $model; + + protected function setUp(): void + { + $registry = new TemplateRendererRegistry([ + new StringTemplateRenderer(), + ]); + + $this->listener = new TemplateRendererListener($registry); + $this->model = new Model('gpt-4o'); + } + + public function testRendersTemplateWhenTemplateVarsProvided() + { + $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() + { + $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() + { + $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 testThrowsExceptionWhenTemplateVarsIsNotArray() + { + $template = Template::string('Hello {name}!'); + $messageBag = new MessageBag(Message::forSystem($template)); + + $event = new InvocationEvent($this->model, $messageBag, [ + 'template_vars' => 'not an array', + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The "template_vars" option must be an array.'); + + ($this->listener)($event); + } + + public function testDoesNothingWhenInputIsNotMessageBag() + { + $event = new InvocationEvent($this->model, 'string input', [ + 'template_vars' => ['name' => 'World'], + ]); + + ($this->listener)($event); + + $this->assertSame('string input', $event->getInput()); + } + + public function testRendersMultipleMessages() + { + $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() + { + $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() + { + $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() + { + $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/ExpressionLanguageTemplateRendererTest.php b/src/platform/tests/Message/TemplateRenderer/ExpressionLanguageTemplateRendererTest.php new file mode 100644 index 000000000..92bfe1a44 --- /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() + { + $this->assertTrue($this->renderer->supports('expression')); + $this->assertFalse($this->renderer->supports('string')); + $this->assertFalse($this->renderer->supports('twig')); + } + + public function testRenderSimpleExpression() + { + $template = Template::expression('price * quantity'); + + $result = $this->renderer->render($template, [ + 'price' => 10, + 'quantity' => 5, + ]); + + $this->assertSame('50', $result); + } + + public function testRenderComplexExpression() + { + $template = Template::expression('(price * quantity) + tax'); + + $result = $this->renderer->render($template, [ + 'price' => 10, + 'quantity' => 5, + 'tax' => 5, + ]); + + $this->assertSame('55', $result); + } + + public function testRenderStringConcatenation() + { + $template = Template::expression('greeting ~ " " ~ name'); + + $result = $this->renderer->render($template, [ + 'greeting' => 'Hello', + 'name' => 'World', + ]); + + $this->assertSame('Hello World', $result); + } + + public function testThrowsExceptionForInvalidExpression() + { + $template = Template::expression('invalid expression syntax {'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Failed to render expression template'); + + $this->renderer->render($template, []); + } + + public function testConstructorThrowsExceptionWhenExpressionLanguageNotAvailable() + { + 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..e91a88aff --- /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() + { + $this->assertTrue($this->renderer->supports('string')); + $this->assertFalse($this->renderer->supports('expression')); + $this->assertFalse($this->renderer->supports('twig')); + } + + public function testRenderSimpleVariable() + { + $template = Template::string('Hello {name}!'); + + $result = $this->renderer->render($template, ['name' => 'World']); + + $this->assertSame('Hello World!', $result); + } + + public function testRenderMultipleVariables() + { + $template = Template::string('{greeting} {name}!'); + + $result = $this->renderer->render($template, [ + 'greeting' => 'Hello', + 'name' => 'World', + ]); + + $this->assertSame('Hello World!', $result); + } + + public function testRenderNumericValue() + { + $template = Template::string('The answer is {answer}'); + + $result = $this->renderer->render($template, ['answer' => 42]); + + $this->assertSame('The answer is 42', $result); + } + + public function testRenderStringableValue() + { + $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() + { + $template = Template::string('Hello {name}!'); + + $result = $this->renderer->render($template, [ + 'name' => 'World', + 'unused' => 'value', + ]); + + $this->assertSame('Hello World!', $result); + } + + public function testRenderWithMissingVariable() + { + $template = Template::string('Hello {name}!'); + + $result = $this->renderer->render($template, []); + + $this->assertSame('Hello {name}!', $result); + } + + public function testThrowsExceptionForNonStringKey() + { + $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() + { + $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/TemplateRenderer/TemplateRendererRegistryTest.php b/src/platform/tests/Message/TemplateRenderer/TemplateRendererRegistryTest.php new file mode 100644 index 000000000..b28102a57 --- /dev/null +++ b/src/platform/tests/Message/TemplateRenderer/TemplateRendererRegistryTest.php @@ -0,0 +1,137 @@ + + * + * 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; +use Symfony\AI\Platform\Message\TemplateRenderer\TemplateRendererInterface; +use Symfony\AI\Platform\Message\TemplateRenderer\TemplateRendererRegistry; +use Symfony\AI\Platform\Message\TemplateRenderer\TemplateRendererRegistryInterface; + +final class TemplateRendererRegistryTest extends TestCase +{ + public function testGetRendererWithSupportedType() + { + $registry = new TemplateRendererRegistry([ + new StringTemplateRenderer(), + ]); + + $template = Template::string('Hello {name}!'); + + $renderer = $registry->getRenderer($template->getType()); + $result = $renderer->render($template, ['name' => 'World']); + + $this->assertSame('Hello World!', $result); + } + + public function testGetRendererSelectsCorrectRenderer() + { + $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 TemplateRendererRegistry([$renderer1, $renderer2]); + + $template = Template::string('Hello {name}!'); + + $renderer = $registry->getRenderer($template->getType()); + $result = $renderer->render($template, ['name' => 'World']); + + $this->assertSame('Hello World!', $result); + $this->assertSame($renderer2, $renderer); + } + + public function testThrowsExceptionForUnsupportedType() + { + $registry = new TemplateRendererRegistry([ + new StringTemplateRenderer(), + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No renderer found for template type "unsupported"'); + + $registry->getRenderer('unsupported'); + } + + public function testAcceptsIterableOfRenderers() + { + $registry = new TemplateRendererRegistry(new \ArrayIterator([ + new StringTemplateRenderer(), + ])); + + $template = Template::string('Hello {name}!'); + + $renderer = $registry->getRenderer($template->getType()); + $result = $renderer->render($template, ['name' => 'World']); + + $this->assertSame('Hello World!', $result); + } + + public function testImplementsRegistryInterface() + { + $registry = new TemplateRendererRegistry([ + new StringTemplateRenderer(), + ]); + + $this->assertInstanceOf(TemplateRendererRegistryInterface::class, $registry); + } + + public function testGetRendererReturnsCorrectRendererForType() + { + $stringRenderer = new StringTemplateRenderer(); + + $registry = new TemplateRendererRegistry([ + $stringRenderer, + ]); + + $renderer = $registry->getRenderer('string'); + + $this->assertSame($stringRenderer, $renderer); + } + + public function testGetRendererWithMultipleRenderers() + { + $renderer1 = new class implements TemplateRendererInterface { + public function supports(string $type): bool + { + return 'custom' === $type; + } + + public function render(Template $template, array $variables): string + { + return 'custom render'; + } + }; + + $renderer2 = new StringTemplateRenderer(); + + $registry = new TemplateRendererRegistry([$renderer1, $renderer2]); + + $customRenderer = $registry->getRenderer('custom'); + $stringRenderer = $registry->getRenderer('string'); + + $this->assertSame($renderer1, $customRenderer); + $this->assertSame($renderer2, $stringRenderer); + } +} diff --git a/src/platform/tests/Message/TemplateTest.php b/src/platform/tests/Message/TemplateTest.php new file mode 100644 index 000000000..e58bd04bd --- /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() + { + $template = new Template('Hello {name}', 'string'); + + $this->assertSame('Hello {name}', $template->getTemplate()); + $this->assertSame('string', $template->getType()); + } + + public function testStringable() + { + $template = new Template('Hello {name}', 'string'); + + $this->assertSame('Hello {name}', (string) $template); + } + + public function testStringNamedConstructor() + { + $template = Template::string('Hello {name}'); + + $this->assertSame('Hello {name}', $template->getTemplate()); + $this->assertSame('string', $template->getType()); + } + + public function testExpressionNamedConstructor() + { + $template = Template::expression('Total: {price * quantity}'); + + $this->assertSame('Total: {price * quantity}', $template->getTemplate()); + $this->assertSame('expression', $template->getType()); + } +}