mirror of
https://github.com/symfony/ai-platform.git
synced 2026-03-23 23:12:22 +01:00
[AI Bundle][Platform] Integrate template rendering into Message API
This commit is contained in:
committed by
Christopher Hertel
parent
e31e160a17
commit
46cf529794
45
AGENTS.md
45
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
|
||||
- Consistent contract interfaces across providers
|
||||
- Template system uses type-based rendering (not renderer injection)
|
||||
- Template rendering via TemplateRendererListener during invocation
|
||||
53
CLAUDE.md
53
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
|
||||
- Component follows Symfony coding standards
|
||||
- Template tests cover all renderer types and integration scenarios
|
||||
|
||||
@@ -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"
|
||||
|
||||
113
src/EventListener/TemplateRendererListener.php
Normal file
113
src/EventListener/TemplateRendererListener.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
56
src/Message/Template.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
50
src/Message/TemplateRenderer/StringTemplateRenderer.php
Normal file
50
src/Message/TemplateRenderer/StringTemplateRenderer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
27
src/Message/TemplateRenderer/TemplateRendererInterface.php
Normal file
27
src/Message/TemplateRenderer/TemplateRendererInterface.php
Normal 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;
|
||||
}
|
||||
48
src/Message/TemplateRenderer/TemplateRendererRegistry.php
Normal file
48
src/Message/TemplateRenderer/TemplateRendererRegistry.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
212
tests/EventListener/TemplateRendererListenerTest.php
Normal file
212
tests/EventListener/TemplateRendererListenerTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
122
tests/Message/TemplateRenderer/StringTemplateRendererTest.php
Normal file
122
tests/Message/TemplateRenderer/StringTemplateRendererTest.php
Normal 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' => []]);
|
||||
}
|
||||
}
|
||||
137
tests/Message/TemplateRenderer/TemplateRendererRegistryTest.php
Normal file
137
tests/Message/TemplateRenderer/TemplateRendererRegistryTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
49
tests/Message/TemplateTest.php
Normal file
49
tests/Message/TemplateTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user