Skip to content

Commit 7538f9f

Browse files
committed
feature #1017 [AI Bundle][Platform] Integrate template rendering into Message API (wachterjohannes)
This PR was squashed before being merged into the main branch. Discussion ---------- [AI Bundle][Platform] Integrate template rendering into Message API | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | Docs? | no | Issues | Fix #258 | License | MIT ## Summary Adds a new **prompt-template** component to the Symfony AI monorepo, filling a gap we identified in our ([Christopher Hertel](https://github.com/chr-hertel) and me) recent discussion about missing core functionality for prompt management. ## Background This implementation merges and adapts code from both [sulu.ai](sulu.ai) and [modelflow-ai](https://github.com/modelflow-ai) projects, bringing battle-tested prompt templating into the Symfony AI ecosystem with a more extensible architecture. ## Architecture The component uses a **strategy pattern** for rendering, allowing different template processors to be plugged in: - **StringRenderer** (default): Simple `{variable}` replacement with zero dependencies - **ExpressionRenderer**: Advanced expression evaluation using Symfony Expression Language (optional) - **Extensible**: Ready for additional renderers like Twig, Mustache, or custom implementations ### Key Features - Zero core dependencies (only PHP 8.2+) - Factory pattern for exceptions with typed error methods - Immutable readonly classes throughout - Interface-first design for maximum flexibility - Comprehensive test coverage (42 tests, 52 assertions) - PHPStan level 6 compliant ## Usage Examples **Simple variable replacement:** ```php $template = PromptTemplate::fromString('Hello {name}!'); echo $template->format(['name' => 'World']); // "Hello World!" Expression-based rendering: $renderer = new ExpressionRenderer(); $template = new PromptTemplate('Total: {price * quantity}', $renderer); echo $template->format(['price' => 10, 'quantity' => 5]); // "Total: 50" Custom renderer: class TwigRenderer implements RendererInterface { public function render(string $template, array $values): string { // Twig implementation } } ``` This provides a solid foundation for prompt template management across the Symfony AI ecosystem while maintaining the flexibility to adapt to different rendering needs. Commits ------- e7dd1b2 [AI Bundle][Platform] Integrate template rendering into Message API
2 parents ae01b42 + e7dd1b2 commit 7538f9f

25 files changed

+1368
-12
lines changed

demo/config/reference.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
<?php
22

3-
// This file is auto-generated and is for apps only. Bundles SHOULD NOT rely on its content.
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
411

512
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
613

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory;
13+
use Symfony\AI\Platform\EventListener\TemplateRendererListener;
14+
use Symfony\AI\Platform\Message\Message;
15+
use Symfony\AI\Platform\Message\MessageBag;
16+
use Symfony\AI\Platform\Message\Template;
17+
use Symfony\AI\Platform\Message\TemplateRenderer\StringTemplateRenderer;
18+
use Symfony\AI\Platform\Message\TemplateRenderer\TemplateRendererRegistry;
19+
use Symfony\Component\EventDispatcher\EventDispatcher;
20+
21+
require_once dirname(__DIR__, 2).'/bootstrap.php';
22+
23+
$eventDispatcher = new EventDispatcher();
24+
$rendererRegistry = new TemplateRendererRegistry([
25+
new StringTemplateRenderer(),
26+
]);
27+
$templateListener = new TemplateRendererListener($rendererRegistry);
28+
$eventDispatcher->addSubscriber($templateListener);
29+
30+
$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client(), eventDispatcher: $eventDispatcher);
31+
32+
echo "UserMessage with mixed content\n";
33+
echo "==============================\n\n";
34+
35+
$messages = new MessageBag(
36+
Message::forSystem('You are a helpful assistant.'),
37+
Message::ofUser('I need help with', Template::string(' {task}'))
38+
);
39+
40+
$result = $platform->invoke('gpt-4o-mini', $messages, [
41+
'template_vars' => ['task' => 'debugging'],
42+
]);
43+
44+
echo "UserMessage: 'Plain text' + Template('{task}')\n";
45+
echo "Variables: ['task' => 'debugging']\n";
46+
echo 'Response: '.$result->asText()."\n";
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory;
13+
use Symfony\AI\Platform\EventListener\TemplateRendererListener;
14+
use Symfony\AI\Platform\Message\Message;
15+
use Symfony\AI\Platform\Message\MessageBag;
16+
use Symfony\AI\Platform\Message\Template;
17+
use Symfony\AI\Platform\Message\TemplateRenderer\StringTemplateRenderer;
18+
use Symfony\AI\Platform\Message\TemplateRenderer\TemplateRendererRegistry;
19+
use Symfony\Component\EventDispatcher\EventDispatcher;
20+
21+
require_once dirname(__DIR__, 2).'/bootstrap.php';
22+
23+
$eventDispatcher = new EventDispatcher();
24+
$rendererRegistry = new TemplateRendererRegistry([
25+
new StringTemplateRenderer(),
26+
]);
27+
$templateListener = new TemplateRendererListener($rendererRegistry);
28+
$eventDispatcher->addSubscriber($templateListener);
29+
30+
$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client(), eventDispatcher: $eventDispatcher);
31+
32+
echo "Multiple messages with templates\n";
33+
echo "=================================\n\n";
34+
35+
$systemTemplate = Template::string('You are a {domain} assistant.');
36+
$userTemplate = Template::string('Calculate {operation}');
37+
38+
$messages = new MessageBag(
39+
Message::forSystem($systemTemplate),
40+
Message::ofUser($userTemplate)
41+
);
42+
43+
$result = $platform->invoke('gpt-4o-mini', $messages, [
44+
'template_vars' => [
45+
'domain' => 'math',
46+
'operation' => '2 + 2',
47+
],
48+
]);
49+
50+
echo "System template: You are a {domain} assistant.\n";
51+
echo "User template: Calculate {operation}\n";
52+
echo "Variables: ['domain' => 'math', 'operation' => '2 + 2']\n";
53+
echo 'Response: '.$result->asText()."\n";
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory;
13+
use Symfony\AI\Platform\EventListener\TemplateRendererListener;
14+
use Symfony\AI\Platform\Message\Message;
15+
use Symfony\AI\Platform\Message\MessageBag;
16+
use Symfony\AI\Platform\Message\Template;
17+
use Symfony\AI\Platform\Message\TemplateRenderer\StringTemplateRenderer;
18+
use Symfony\AI\Platform\Message\TemplateRenderer\TemplateRendererRegistry;
19+
use Symfony\Component\EventDispatcher\EventDispatcher;
20+
21+
require_once dirname(__DIR__, 2).'/bootstrap.php';
22+
23+
$eventDispatcher = new EventDispatcher();
24+
$rendererRegistry = new TemplateRendererRegistry([
25+
new StringTemplateRenderer(),
26+
]);
27+
$templateListener = new TemplateRendererListener($rendererRegistry);
28+
$eventDispatcher->addSubscriber($templateListener);
29+
30+
$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client(), eventDispatcher: $eventDispatcher);
31+
32+
echo "SystemMessage with template\n";
33+
echo "===========================\n\n";
34+
35+
$template = Template::string('You are a {domain} expert assistant.');
36+
$messages = new MessageBag(
37+
Message::forSystem($template),
38+
Message::ofUser('What is PHP?')
39+
);
40+
41+
$result = $platform->invoke('gpt-4o-mini', $messages, [
42+
'template_vars' => ['domain' => 'programming'],
43+
]);
44+
45+
echo "SystemMessage template: You are a {domain} expert assistant.\n";
46+
echo "Variables: ['domain' => 'programming']\n";
47+
echo 'Response: '.$result->asText()."\n";
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory;
13+
use Symfony\AI\Platform\EventListener\TemplateRendererListener;
14+
use Symfony\AI\Platform\Message\Message;
15+
use Symfony\AI\Platform\Message\MessageBag;
16+
use Symfony\AI\Platform\Message\Template;
17+
use Symfony\AI\Platform\Message\TemplateRenderer\StringTemplateRenderer;
18+
use Symfony\AI\Platform\Message\TemplateRenderer\TemplateRendererRegistry;
19+
use Symfony\Component\EventDispatcher\EventDispatcher;
20+
21+
require_once dirname(__DIR__, 2).'/bootstrap.php';
22+
23+
$eventDispatcher = new EventDispatcher();
24+
$rendererRegistry = new TemplateRendererRegistry([
25+
new StringTemplateRenderer(),
26+
]);
27+
$templateListener = new TemplateRendererListener($rendererRegistry);
28+
$eventDispatcher->addSubscriber($templateListener);
29+
30+
$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client(), eventDispatcher: $eventDispatcher);
31+
32+
echo "UserMessage with template\n";
33+
echo "=========================\n\n";
34+
35+
$messages = new MessageBag(
36+
Message::forSystem('You are a helpful assistant.'),
37+
Message::ofUser(Template::string('Tell me about {topic}'))
38+
);
39+
40+
$result = $platform->invoke('gpt-4o-mini', $messages, [
41+
'template_vars' => ['topic' => 'PHP'],
42+
]);
43+
44+
echo "UserMessage template: Tell me about {topic}\n";
45+
echo "Variables: ['topic' => 'PHP']\n";
46+
echo 'Response: '.$result->asText()."\n";

src/ai-bundle/config/services.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@
6464
use Symfony\AI\Platform\Contract;
6565
use Symfony\AI\Platform\Contract\JsonSchema\DescriptionParser;
6666
use Symfony\AI\Platform\Contract\JsonSchema\Factory as SchemaFactory;
67+
use Symfony\AI\Platform\EventListener\TemplateRendererListener;
68+
use Symfony\AI\Platform\Message\TemplateRenderer\ExpressionLanguageTemplateRenderer;
69+
use Symfony\AI\Platform\Message\TemplateRenderer\StringTemplateRenderer;
70+
use Symfony\AI\Platform\Message\TemplateRenderer\TemplateRendererRegistry;
6771
use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface;
6872
use Symfony\AI\Platform\Serializer\StructuredOutputSerializer;
6973
use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber;
@@ -73,6 +77,7 @@
7377
use Symfony\AI\Store\Command\IndexCommand;
7478
use Symfony\AI\Store\Command\RetrieveCommand;
7579
use Symfony\AI\Store\Command\SetupStoreCommand;
80+
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
7681

7782
return static function (ContainerConfigurator $container): void {
7883
$container->services()
@@ -119,6 +124,30 @@
119124
->set('ai.platform.model_catalog.vertexai.gemini', VertexAiModelCatalog::class)
120125
->set('ai.platform.model_catalog.voyage', VoyageModelCatalog::class)
121126

127+
// message templates
128+
->set('ai.platform.template_renderer.string', StringTemplateRenderer::class)
129+
->tag('ai.platform.template_renderer');
130+
131+
if (class_exists(ExpressionLanguage::class)) {
132+
$container->services()
133+
->set('ai.platform.template_renderer.expression', ExpressionLanguageTemplateRenderer::class)
134+
->args([
135+
service('expression_language'),
136+
])
137+
->tag('ai.platform.template_renderer');
138+
}
139+
140+
$container->services()
141+
->set('ai.platform.template_renderer_registry', TemplateRendererRegistry::class)
142+
->args([
143+
tagged_iterator('ai.platform.template_renderer'),
144+
])
145+
->set('ai.platform.template_renderer_listener', TemplateRendererListener::class)
146+
->args([
147+
service('ai.platform.template_renderer_registry'),
148+
])
149+
->tag('kernel.event_subscriber')
150+
122151
// structured output
123152
->set('ai.agent.response_format_factory', ResponseFormatFactory::class)
124153
->args([

src/ai-bundle/tests/DependencyInjection/AiBundleTest.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@
3333
use Symfony\AI\Platform\Bridge\ElevenLabs\PlatformFactory as ElevenLabsPlatformFactory;
3434
use Symfony\AI\Platform\Bridge\Ollama\OllamaApiCatalog;
3535
use Symfony\AI\Platform\Capability;
36+
use Symfony\AI\Platform\EventListener\TemplateRendererListener;
37+
use Symfony\AI\Platform\Message\TemplateRenderer\ExpressionLanguageTemplateRenderer;
38+
use Symfony\AI\Platform\Message\TemplateRenderer\StringTemplateRenderer;
39+
use Symfony\AI\Platform\Message\TemplateRenderer\TemplateRendererRegistry;
3640
use Symfony\AI\Platform\Model;
3741
use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface;
3842
use Symfony\AI\Platform\PlatformInterface;
@@ -6978,6 +6982,42 @@ public function testModelConfigurationIsIgnoredForUnknownPlatform()
69786982
$this->assertSame([], $definition->getArguments());
69796983
}
69806984

6985+
public function testTemplateRendererServicesAreRegistered()
6986+
{
6987+
$container = $this->buildContainer([
6988+
'ai' => [
6989+
'platform' => [
6990+
'anthropic' => [
6991+
'api_key' => 'test_key',
6992+
],
6993+
],
6994+
],
6995+
]);
6996+
6997+
// Verify string template renderer is registered
6998+
$this->assertTrue($container->hasDefinition('ai.platform.template_renderer.string'));
6999+
$stringRendererDefinition = $container->getDefinition('ai.platform.template_renderer.string');
7000+
$this->assertSame(StringTemplateRenderer::class, $stringRendererDefinition->getClass());
7001+
$this->assertTrue($stringRendererDefinition->hasTag('ai.platform.template_renderer'));
7002+
7003+
// Verify expression template renderer is registered
7004+
$this->assertTrue($container->hasDefinition('ai.platform.template_renderer.expression'));
7005+
$expressionRendererDefinition = $container->getDefinition('ai.platform.template_renderer.expression');
7006+
$this->assertSame(ExpressionLanguageTemplateRenderer::class, $expressionRendererDefinition->getClass());
7007+
$this->assertTrue($expressionRendererDefinition->hasTag('ai.platform.template_renderer'));
7008+
7009+
// Verify template renderer registry is registered
7010+
$this->assertTrue($container->hasDefinition('ai.platform.template_renderer_registry'));
7011+
$registryDefinition = $container->getDefinition('ai.platform.template_renderer_registry');
7012+
$this->assertSame(TemplateRendererRegistry::class, $registryDefinition->getClass());
7013+
7014+
// Verify template renderer listener is registered as event subscriber
7015+
$this->assertTrue($container->hasDefinition('ai.platform.template_renderer_listener'));
7016+
$listenerDefinition = $container->getDefinition('ai.platform.template_renderer_listener');
7017+
$this->assertSame(TemplateRendererListener::class, $listenerDefinition->getClass());
7018+
$this->assertTrue($listenerDefinition->hasTag('kernel.event_subscriber'));
7019+
}
7020+
69817021
private function buildContainer(array $configuration): ContainerBuilder
69827022
{
69837023
$container = new ContainerBuilder();

src/platform/AGENTS.md

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@ Unified abstraction for AI platforms (OpenAI, Anthropic, Azure, Gemini, VertexAI
1313
- **Model**: AI models with provider-specific configurations
1414
- **Contract**: Abstract contracts for AI capabilities (chat, embedding, speech)
1515
- **Message**: Message system for AI interactions
16+
- **Template**: Message templating with pluggable rendering strategies
1617
- **Tool**: Function calling capabilities
1718
- **Bridge**: Provider-specific implementations
1819

1920
### Key Directories
2021
- `src/Bridge/`: Provider implementations
2122
- `src/Contract/`: Abstract contracts and interfaces
22-
- `src/Message/`: Message handling system
23+
- `src/Message/`: Message handling system with Template support
24+
- `src/Message/TemplateRenderer/`: Template rendering strategies
2325
- `src/Tool/`: Function calling and tool definitions
2426
- `src/Result/`: Result types and converters
2527
- `src/Exception/`: Platform-specific exceptions
@@ -54,11 +56,50 @@ composer install
5456
composer update
5557
```
5658

59+
## Usage Patterns
60+
61+
### Message Templates
62+
63+
Templates support variable substitution with type-based rendering. SystemMessage and UserMessage support templates.
64+
65+
```php
66+
use Symfony\AI\Platform\Message\Message;
67+
use Symfony\AI\Platform\Message\MessageBag;
68+
use Symfony\AI\Platform\Message\Template;
69+
70+
// SystemMessage with template
71+
$template = Template::string('You are a {role} assistant.');
72+
$message = Message::forSystem($template);
73+
74+
// UserMessage with template
75+
$message = Message::ofUser(Template::string('Calculate {operation}'));
76+
77+
// Multiple messages with templates
78+
$messages = new MessageBag(
79+
Message::forSystem(Template::string('You are a {role} assistant.')),
80+
Message::ofUser(Template::string('Calculate {operation}'))
81+
);
82+
83+
$result = $platform->invoke('gpt-4o-mini', $messages, [
84+
'template_vars' => [
85+
'role' => 'helpful',
86+
'operation' => '2 + 2',
87+
],
88+
]);
89+
90+
// Expression template (requires symfony/expression-language)
91+
$template = Template::expression('price * quantity');
92+
```
93+
94+
Rendering happens externally during `Platform.invoke()` when `template_vars` option is provided.
95+
5796
## Development Notes
5897

5998
- PHPUnit 11+ with strict configuration
6099
- Test fixtures in `../../fixtures` for multimodal content
61100
- MockHttpClient pattern preferred
62101
- Follows Symfony coding standards
63102
- Bridge pattern for provider implementations
64-
- Consistent contract interfaces across providers
103+
- Consistent contract interfaces across providers
104+
- Template system uses type-based rendering (not renderer injection)
105+
- Template rendering via TemplateRendererListener during invocation

0 commit comments

Comments
 (0)