[AI Bundle][Platform] Integrate template rendering into Message API

This commit is contained in:
Johannes Wachter
2025-11-28 19:46:04 +01:00
committed by Christopher Hertel
parent e31e160a17
commit 46cf529794
18 changed files with 1099 additions and 11 deletions

View File

@@ -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
- Consistent contract interfaces across providers
- Template system uses type-based rendering (not renderer injection)
- Template rendering via TemplateRendererListener during invocation

View File

@@ -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
- Component follows Symfony coding standards
- Template tests cover all renderer types and integration scenarios

View File

@@ -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"

View File

@@ -0,0 +1,113 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <johannes@sulu.io>
*/
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<string, mixed> $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;
}
}

View File

@@ -26,8 +26,12 @@ final class Message
{
}
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 @@ final class Message
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,
);

View File

@@ -26,9 +26,9 @@ interface MessageInterface
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;
}

View File

@@ -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 @@ final class SystemMessage implements MessageInterface
return $this->id;
}
public function getContent(): string
public function getContent(): string|Template
{
return $this->content;
}

56
src/Message/Template.php Normal file
View File

@@ -0,0 +1,56 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <johannes@sulu.io>
*/
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');
}
}

View File

@@ -0,0 +1,50 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <johannes@sulu.io>
*/
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);
}
}
}

View File

@@ -0,0 +1,50 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <johannes@sulu.io>
*/
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;
}
}

View File

@@ -0,0 +1,27 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <johannes@sulu.io>
*/
interface TemplateRendererInterface
{
public function supports(string $type): bool;
/**
* @param array<string, mixed> $variables
*/
public function render(Template $template, array $variables): string;
}

View File

@@ -0,0 +1,48 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <johannes@sulu.io>
*/
final class TemplateRendererRegistry implements TemplateRendererRegistryInterface
{
/**
* @var TemplateRendererInterface[]
*/
private readonly array $renderers;
/**
* @param iterable<TemplateRendererInterface> $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));
}
}

View File

@@ -0,0 +1,29 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <johannes@sulu.io>
*/
interface TemplateRendererRegistryInterface
{
/**
* @throws InvalidArgumentException If no renderer supports the type
*/
public function getRenderer(string $type): TemplateRendererInterface;
}

View File

@@ -0,0 +1,212 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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());
}
}

View File

@@ -0,0 +1,98 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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();
}
}

View File

@@ -0,0 +1,122 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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' => []]);
}
}

View File

@@ -0,0 +1,137 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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);
}
}

View File

@@ -0,0 +1,49 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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());
}
}