From 00b4af3327a519c96d72cb986e3b4c8b66b683dc Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Fri, 28 Nov 2025 19:46:04 +0100 Subject: [PATCH] [PromptTemplate] Add new component with extensible renderer architecture Introduces a new prompt-template component providing simple yet extensible prompt templating with pluggable rendering strategies. Features: - Zero core dependencies (only PHP 8.2+) - StringRenderer for simple {variable} replacement (default) - ExpressionRenderer for advanced expressions (optional, requires symfony/expression-language) - Factory pattern for exceptions with typed error methods - Comprehensive test coverage (42 tests, 52 assertions) - PHPStan level 6 compliant - Follows Symfony coding standards Architecture: - Strategy pattern for extensible rendering - Immutable readonly classes throughout - Interface-first design (PromptTemplateInterface, RendererInterface) - Component-specific exception hierarchy The component allows users to create prompt templates with variable substitution using either simple string replacement or advanced expression evaluation, with the ability to implement custom renderers for specific use cases. --- src/prompt-template/.gitattributes | 7 + src/prompt-template/AGENTS.md | 96 ++++++++++ src/prompt-template/CHANGELOG.md | 12 ++ src/prompt-template/CLAUDE.md | 116 ++++++++++++ src/prompt-template/LICENSE | 19 ++ src/prompt-template/README.md | 142 ++++++++++++++ src/prompt-template/composer.json | 55 ++++++ src/prompt-template/phpstan.dist.neon | 12 ++ src/prompt-template/phpunit.xml.dist | 22 +++ .../src/Exception/ExceptionInterface.php | 19 ++ .../Exception/InvalidArgumentException.php | 28 +++ .../src/Exception/RenderingException.php | 28 +++ .../src/Exception/RuntimeException.php | 19 ++ src/prompt-template/src/PromptTemplate.php | 60 ++++++ .../src/PromptTemplateInterface.php | 30 +++ .../src/Renderer/ExpressionRenderer.php | 63 +++++++ .../src/Renderer/RendererInterface.php | 27 +++ .../src/Renderer/StringRenderer.php | 44 +++++ .../tests/PromptTemplateTest.php | 176 ++++++++++++++++++ .../tests/Renderer/ExpressionRendererTest.php | 163 ++++++++++++++++ .../tests/Renderer/StringRendererTest.php | 153 +++++++++++++++ 21 files changed, 1291 insertions(+) create mode 100644 src/prompt-template/.gitattributes create mode 100644 src/prompt-template/AGENTS.md create mode 100644 src/prompt-template/CHANGELOG.md create mode 100644 src/prompt-template/CLAUDE.md create mode 100644 src/prompt-template/LICENSE create mode 100644 src/prompt-template/README.md create mode 100644 src/prompt-template/composer.json create mode 100644 src/prompt-template/phpstan.dist.neon create mode 100644 src/prompt-template/phpunit.xml.dist create mode 100644 src/prompt-template/src/Exception/ExceptionInterface.php create mode 100644 src/prompt-template/src/Exception/InvalidArgumentException.php create mode 100644 src/prompt-template/src/Exception/RenderingException.php create mode 100644 src/prompt-template/src/Exception/RuntimeException.php create mode 100644 src/prompt-template/src/PromptTemplate.php create mode 100644 src/prompt-template/src/PromptTemplateInterface.php create mode 100644 src/prompt-template/src/Renderer/ExpressionRenderer.php create mode 100644 src/prompt-template/src/Renderer/RendererInterface.php create mode 100644 src/prompt-template/src/Renderer/StringRenderer.php create mode 100644 src/prompt-template/tests/PromptTemplateTest.php create mode 100644 src/prompt-template/tests/Renderer/ExpressionRendererTest.php create mode 100644 src/prompt-template/tests/Renderer/StringRendererTest.php diff --git a/src/prompt-template/.gitattributes b/src/prompt-template/.gitattributes new file mode 100644 index 000000000..3310df338 --- /dev/null +++ b/src/prompt-template/.gitattributes @@ -0,0 +1,7 @@ +/.gitattributes export-ignore +/.gitignore export-ignore +/phpstan.dist.neon export-ignore +/phpunit.xml.dist export-ignore +/tests export-ignore +/AGENTS.md export-ignore +/CLAUDE.md export-ignore diff --git a/src/prompt-template/AGENTS.md b/src/prompt-template/AGENTS.md new file mode 100644 index 000000000..54bd8ceec --- /dev/null +++ b/src/prompt-template/AGENTS.md @@ -0,0 +1,96 @@ +# AGENTS.md + +AI agent guidance for the Prompt Template component. + +## Component Overview + +Extensible prompt templating system with pluggable rendering strategies. Provides simple variable substitution by default and optional Symfony Expression Language integration for advanced use cases. + +## Architecture + +### Core Classes +- **PromptTemplate**: Main implementation with renderer injection +- **PromptTemplateInterface**: Core contract for template implementations +- **RendererInterface**: Strategy interface for rendering implementations +- **StringRenderer**: Default renderer using simple {variable} replacement (zero dependencies) +- **ExpressionRenderer**: Optional renderer using Symfony Expression Language for advanced expressions + +### Key Features +- **Zero Core Dependencies**: Only requires PHP 8.2+ +- **Strategy Pattern**: Pluggable renderer system for extensibility +- **Simple by Default**: Basic {variable} replacement out of the box +- **Advanced Optional**: Expression Language for power users +- **Immutable**: readonly classes ensure thread-safety + +### Key Directories +- `src/`: Source code with main classes +- `src/Renderer/`: Renderer implementations +- `src/Exception/`: Component-specific exceptions (factory pattern) +- `tests/`: Comprehensive test suite mirroring src structure +- `tests/Renderer/`: Tests for each renderer + +## Essential Commands + +### Testing +```bash +vendor/bin/phpunit +vendor/bin/phpunit tests/PromptTemplateTest.php +vendor/bin/phpunit --coverage-html coverage +``` + +### Code Quality +```bash +vendor/bin/phpstan analyse +cd ../../.. && vendor/bin/php-cs-fixer fix src/prompt-template/ +``` + +### Dependencies +```bash +composer install +composer require symfony/expression-language # For ExpressionRenderer +``` + +## Usage Patterns + +### Default Renderer +```php +$template = new PromptTemplate('Hello {name}!'); +echo $template->format(['name' => 'World']); +``` + +### Expression Renderer +```php +$renderer = new ExpressionRenderer(); +$template = new PromptTemplate('Total: {price * quantity}', $renderer); +echo $template->format(['price' => 10, 'quantity' => 5]); +``` + +### Custom Renderer +```php +class MyRenderer implements RendererInterface { + public function render(string $template, array $values): string { + // Custom implementation + } +} + +$template = new PromptTemplate($template, new MyRenderer()); +``` + +## Testing Patterns + +- PHPUnit 11.5+ with strict configuration +- Test fixtures follow monorepo patterns +- Each renderer has dedicated test class +- Integration tests verify renderer injection +- Component-specific exception testing using factory pattern +- Prefer `self::assert*` over `$this->assert*` + +## Development Notes + +- Add `@author` tags to new classes +- Use component-specific exceptions from `src/Exception/` with factory pattern +- Follow Symfony coding standards with `@Symfony` PHP CS Fixer rules +- Component is experimental (BC breaks possible) +- StringRenderer must remain dependency-free +- ExpressionRenderer requires symfony/expression-language +- All classes use readonly for immutability diff --git a/src/prompt-template/CHANGELOG.md b/src/prompt-template/CHANGELOG.md new file mode 100644 index 000000000..ebda99380 --- /dev/null +++ b/src/prompt-template/CHANGELOG.md @@ -0,0 +1,12 @@ +# CHANGELOG + +## 0.1.0 + +**Initial release** + +- Introduced `PromptTemplate` for prompt template management +- Added `StringRenderer` for simple {variable} replacement (zero dependencies) +- Added `ExpressionRenderer` for Symfony Expression Language integration +- Implemented extensible renderer strategy pattern via `RendererInterface` +- Added comprehensive exception hierarchy +- Provides static factory methods for convenient template creation diff --git a/src/prompt-template/CLAUDE.md b/src/prompt-template/CLAUDE.md new file mode 100644 index 000000000..3713f97ef --- /dev/null +++ b/src/prompt-template/CLAUDE.md @@ -0,0 +1,116 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this component. + +## Component Overview + +This is the Prompt Template component of the Symfony AI monorepo - an extensible prompt templating system with pluggable rendering strategies. It provides simple variable substitution by default and optional Symfony Expression Language integration for advanced use cases. + +## Architecture + +### Core Classes + +- **PromptTemplate**: Main implementation with renderer injection +- **PromptTemplateInterface**: Core contract for template implementations +- **RendererInterface**: Strategy interface for rendering implementations +- **StringRenderer**: Default renderer using simple {variable} replacement (zero dependencies) +- **ExpressionRenderer**: Optional renderer using Symfony Expression Language for advanced expressions + +### Key Features + +- **Zero Core Dependencies**: Only requires PHP 8.2+ +- **Strategy Pattern**: Pluggable renderer system for extensibility +- **Simple by Default**: Basic {variable} replacement out of the box +- **Advanced Optional**: Expression Language for power users +- **Immutable**: readonly classes ensure thread-safety + +### Key Directories + +- `src/`: Source code with main classes +- `src/Renderer/`: Renderer implementations +- `src/Exception/`: Component-specific exceptions +- `tests/`: Comprehensive test suite mirroring src structure +- `tests/Renderer/`: Tests for each renderer + +## Development Commands + +### Testing + +```bash +# Run all tests +vendor/bin/phpunit + +# Run specific test +vendor/bin/phpunit tests/PromptTemplateTest.php + +# Run with coverage +vendor/bin/phpunit --coverage-html coverage +``` + +### Code Quality + +```bash +# Run PHPStan static analysis +vendor/bin/phpstan analyse + +# Fix code style (run from project root) +cd ../../.. && vendor/bin/php-cs-fixer fix src/prompt-template/ +``` + +### Installing Dependencies + +```bash +# Install dependencies +composer install + +# Install with expression language +composer require symfony/expression-language +``` + +## Testing Architecture + +- Uses PHPUnit 11.5+ with strict configuration +- Test fixtures follow monorepo patterns +- Each renderer has dedicated test class +- Integration tests verify renderer injection +- Component-specific exception testing +- Prefer `self::assert*` over `$this->assert*` + +## Usage Patterns + +### Default Renderer + +```php +$template = new PromptTemplate('Hello {name}!'); +echo $template->format(['name' => 'World']); +``` + +### Expression Renderer + +```php +$renderer = new ExpressionRenderer(); +$template = new PromptTemplate('Total: {price * quantity}', $renderer); +echo $template->format(['price' => 10, 'quantity' => 5]); +``` + +### Custom Renderer + +```php +class MyRenderer implements RendererInterface { + public function render(string $template, array $values): string { + // Custom implementation + } +} + +$template = new PromptTemplate($template, new MyRenderer()); +``` + +## Development Notes + +- All new classes should have `@author` tags +- Use component-specific exceptions from `src/Exception/` +- Follow Symfony coding standards with `@Symfony` PHP CS Fixer rules +- Component is marked as experimental (BC breaks possible) +- StringRenderer must remain dependency-free +- ExpressionRenderer requires symfony/expression-language +- All classes use readonly for immutability diff --git a/src/prompt-template/LICENSE b/src/prompt-template/LICENSE new file mode 100644 index 000000000..bc38d714e --- /dev/null +++ b/src/prompt-template/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2025-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/prompt-template/README.md b/src/prompt-template/README.md new file mode 100644 index 000000000..8ae42b5f3 --- /dev/null +++ b/src/prompt-template/README.md @@ -0,0 +1,142 @@ +# Symfony AI Prompt Template Component + +**This Component is [experimental](https://github.com/symfony/ai?tab=readme-ov-file#component-status). Expect breaking changes in minor and patch versions.** + +PHP library for prompt template management with extensible rendering strategies. + +## Installation + +```bash +composer require symfony/ai-prompt-template +``` + +For advanced expression-based rendering: + +```bash +composer require symfony/expression-language +``` + +## Basic Usage + +### Simple Variable Replacement (Default) + +```php +use Symfony\AI\PromptTemplate\PromptTemplate; + +$template = new PromptTemplate('Hello {name}!'); +echo $template->format(['name' => 'World']); // "Hello World!" + +// Using static factory +$template = PromptTemplate::fromString(<<<'PROMPT' +You are a helpful assistant. + +User: {user} +Query: {query} +PROMPT); + +echo $template->format([ + 'user' => 'Alice', + 'query' => 'What is AI?', +]); +``` + +The default `StringRenderer` performs simple `{variable}` replacement with zero external dependencies. + +### Advanced Expression-Based Rendering + +Install `symfony/expression-language` to use the `ExpressionRenderer`: + +```php +use Symfony\AI\PromptTemplate\PromptTemplate; +use Symfony\AI\PromptTemplate\Renderer\ExpressionRenderer; + +$renderer = new ExpressionRenderer(); +$template = new PromptTemplate('Total: {price * quantity}', $renderer); +echo $template->format(['price' => 10, 'quantity' => 5]); // "Total: 50" + +// Conditional expressions +$template = PromptTemplate::fromStringWithRenderer( + 'Status: {age >= 18 ? "adult" : "minor"}', + new ExpressionRenderer() +); +echo $template->format(['age' => 25]); // "Status: adult" + +// Object property access +$template = new PromptTemplate('Name: {user.name}', $renderer); +echo $template->format(['user' => $userObject]); // "Name: Alice" +``` + +### Custom Renderers + +Implement `RendererInterface` to create custom rendering strategies: + +```php +use Symfony\AI\PromptTemplate\Renderer\RendererInterface; + +class MustacheRenderer implements RendererInterface +{ + public function render(string $template, array $values): string + { + foreach ($values as $key => $value) { + $template = str_replace('{{'.$key.'}}', (string) $value, $template); + } + return $template; + } +} + +$template = new PromptTemplate('Hello {{name}}!', new MustacheRenderer()); +echo $template->format(['name' => 'World']); // "Hello World!" +``` + +## Available Renderers + +### StringRenderer (Default) + +- Simple `{variable}` placeholder replacement +- Validates variable names and values +- Zero external dependencies +- Supports strings, numbers, and `\Stringable` objects + +### ExpressionRenderer (Optional) + +Requires `symfony/expression-language` + +- Variable access: `{user.name}` +- Math operations: `{price * quantity}` +- Conditionals: `{age > 18 ? "adult" : "minor"}` +- String concatenation: `{firstName ~ " " ~ lastName}` +- Array access: `{items[0]}` +- Custom functions via `ExpressionLanguage` + +## API Reference + +### PromptTemplate + +```php +// Constructor +new PromptTemplate(string $template, ?RendererInterface $renderer = null) + +// Methods +$template->format(array $values = []): string +$template->getTemplate(): string +(string) $template // Returns template string + +// Static factories +PromptTemplate::fromString(string $template): self +PromptTemplate::fromStringWithRenderer(string $template, RendererInterface $renderer): self +``` + +### RendererInterface + +```php +interface RendererInterface +{ + public function render(string $template, array $values): string; +} +``` + +## Resources + +- [Main Repository](https://github.com/symfony/ai) +- [Report Issues](https://github.com/symfony/ai/issues) +- [Submit Pull Requests](https://github.com/symfony/ai/pulls) diff --git a/src/prompt-template/composer.json b/src/prompt-template/composer.json new file mode 100644 index 000000000..cedf4de95 --- /dev/null +++ b/src/prompt-template/composer.json @@ -0,0 +1,55 @@ +{ + "name": "symfony/ai-prompt-template", + "description": "PHP library for prompt template management with extensible rendering strategies.", + "license": "MIT", + "type": "library", + "keywords": [ + "ai", + "llm", + "prompt", + "template" + ], + "authors": [ + { + "name": "Johannes Wachter", + "email": "johannes@sulu.io" + } + ], + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.5.13", + "symfony/expression-language": "^7.3|^8.0" + }, + "suggest": { + "symfony/expression-language": "For advanced expression-based template rendering" + }, + "minimum-stability": "dev", + "autoload": { + "psr-4": { + "Symfony\\AI\\PromptTemplate\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Symfony\\AI\\Fixtures\\": "../../fixtures", + "Symfony\\AI\\PHPStan\\": "../../.phpstan/", + "Symfony\\AI\\PromptTemplate\\Tests\\": "tests/" + } + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "0.x-dev" + }, + "thanks": { + "name": "symfony/ai", + "url": "https://github.com/symfony/ai" + } + } +} diff --git a/src/prompt-template/phpstan.dist.neon b/src/prompt-template/phpstan.dist.neon new file mode 100644 index 000000000..988f6c4d0 --- /dev/null +++ b/src/prompt-template/phpstan.dist.neon @@ -0,0 +1,12 @@ +includes: + - ../../.phpstan/extension.neon + +parameters: + level: 6 + paths: + - src/ + - tests/ + treatPhpDocTypesAsCertain: false + ignoreErrors: + - + message: "#^Method .*::test.*\\(\\) has no return type specified\\.$#" diff --git a/src/prompt-template/phpunit.xml.dist b/src/prompt-template/phpunit.xml.dist new file mode 100644 index 000000000..181c0463c --- /dev/null +++ b/src/prompt-template/phpunit.xml.dist @@ -0,0 +1,22 @@ + + + + + tests + + + + + + src + + + diff --git a/src/prompt-template/src/Exception/ExceptionInterface.php b/src/prompt-template/src/Exception/ExceptionInterface.php new file mode 100644 index 000000000..09e75d6eb --- /dev/null +++ b/src/prompt-template/src/Exception/ExceptionInterface.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\PromptTemplate\Exception; + +/** + * @author Johannes Wachter + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/prompt-template/src/Exception/InvalidArgumentException.php b/src/prompt-template/src/Exception/InvalidArgumentException.php new file mode 100644 index 000000000..4383d3b1c --- /dev/null +++ b/src/prompt-template/src/Exception/InvalidArgumentException.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\PromptTemplate\Exception; + +/** + * @author Johannes Wachter + */ +final class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface +{ + public static function invalidVariableName(string $type): self + { + return new self(\sprintf('Variable name must be a string, "%s" given.', $type)); + } + + public static function invalidVariableValue(string $variableName, string $type): self + { + return new self(\sprintf('Variable "%s" must be a string, numeric, or Stringable, "%s" given.', $variableName, $type)); + } +} diff --git a/src/prompt-template/src/Exception/RenderingException.php b/src/prompt-template/src/Exception/RenderingException.php new file mode 100644 index 000000000..f11864884 --- /dev/null +++ b/src/prompt-template/src/Exception/RenderingException.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\PromptTemplate\Exception; + +/** + * @author Johannes Wachter + */ +final class RenderingException extends RuntimeException +{ + public static function expressionEvaluationFailed(string $expression, \Throwable $previous): self + { + return new self(\sprintf('Failed to render expression "%s": %s', $expression, $previous->getMessage()), previous: $previous); + } + + public static function templateProcessingFailed(): self + { + return new self('Failed to process template.'); + } +} diff --git a/src/prompt-template/src/Exception/RuntimeException.php b/src/prompt-template/src/Exception/RuntimeException.php new file mode 100644 index 000000000..5f3d85be6 --- /dev/null +++ b/src/prompt-template/src/Exception/RuntimeException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\PromptTemplate\Exception; + +/** + * @author Johannes Wachter + */ +class RuntimeException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/prompt-template/src/PromptTemplate.php b/src/prompt-template/src/PromptTemplate.php new file mode 100644 index 000000000..bfa02218e --- /dev/null +++ b/src/prompt-template/src/PromptTemplate.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\PromptTemplate; + +use Symfony\AI\PromptTemplate\Renderer\RendererInterface; +use Symfony\AI\PromptTemplate\Renderer\StringRenderer; + +/** + * Prompt template with extensible rendering strategy. + * + * Supports variable substitution using a pluggable renderer system. + * Defaults to simple {variable} replacement via StringRenderer. + * + * @author Johannes Wachter + */ +final readonly class PromptTemplate implements PromptTemplateInterface +{ + private RendererInterface $renderer; + + public function __construct( + private string $template, + ?RendererInterface $renderer = null, + ) { + $this->renderer = $renderer ?? new StringRenderer(); + } + + public function __toString(): string + { + return $this->template; + } + + public function format(array $values = []): string + { + return $this->renderer->render($this->template, $values); + } + + public function getTemplate(): string + { + return $this->template; + } + + public static function fromString(string $template): self + { + return new self($template); + } + + public static function fromStringWithRenderer(string $template, RendererInterface $renderer): self + { + return new self($template, $renderer); + } +} diff --git a/src/prompt-template/src/PromptTemplateInterface.php b/src/prompt-template/src/PromptTemplateInterface.php new file mode 100644 index 000000000..356a688fa --- /dev/null +++ b/src/prompt-template/src/PromptTemplateInterface.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\PromptTemplate; + +/** + * @author Johannes Wachter + */ +interface PromptTemplateInterface extends \Stringable +{ + /** + * Renders the template with the provided values. + * + * @param array $values + */ + public function format(array $values = []): string; + + /** + * Returns the original template string. + */ + public function getTemplate(): string; +} diff --git a/src/prompt-template/src/Renderer/ExpressionRenderer.php b/src/prompt-template/src/Renderer/ExpressionRenderer.php new file mode 100644 index 000000000..e42f5aafb --- /dev/null +++ b/src/prompt-template/src/Renderer/ExpressionRenderer.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\PromptTemplate\Renderer; + +use Symfony\AI\PromptTemplate\Exception\RenderingException; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; + +/** + * Renderer using Symfony Expression Language. + * + * Supports {expression} placeholders with full expression syntax including: + * - Variable access: {user.name} + * - Math operations: {price * quantity} + * - Conditionals: {age > 18 ? "adult" : "minor"} + * - String methods: {name.upper()} + * - Array access: {items[0]} + * + * @author Johannes Wachter + */ +final readonly class ExpressionRenderer implements RendererInterface +{ + private ExpressionLanguage $expressionLanguage; + + public function __construct(?ExpressionLanguage $expressionLanguage = null) + { + $this->expressionLanguage = $expressionLanguage ?? new ExpressionLanguage(); + } + + public function render(string $template, array $values): string + { + $result = preg_replace_callback( + '/{([^}]+)}/', + function (array $matches) use ($values): string { + try { + $evaluated = $this->expressionLanguage->evaluate( + $matches[1], + $values + ); + + return (string) $evaluated; + } catch (\Throwable $e) { + throw RenderingException::expressionEvaluationFailed($matches[1], $e); + } + }, + $template + ); + + if (null === $result) { + throw RenderingException::templateProcessingFailed(); + } + + return $result; + } +} diff --git a/src/prompt-template/src/Renderer/RendererInterface.php b/src/prompt-template/src/Renderer/RendererInterface.php new file mode 100644 index 000000000..4b4dfbe94 --- /dev/null +++ b/src/prompt-template/src/Renderer/RendererInterface.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\PromptTemplate\Renderer; + +use Symfony\AI\PromptTemplate\Exception\RenderingException; + +/** + * @author Johannes Wachter + */ +interface RendererInterface +{ + /** + * @param array $values + * + * @throws RenderingException when rendering fails + */ + public function render(string $template, array $values): string; +} diff --git a/src/prompt-template/src/Renderer/StringRenderer.php b/src/prompt-template/src/Renderer/StringRenderer.php new file mode 100644 index 000000000..3b66f02c1 --- /dev/null +++ b/src/prompt-template/src/Renderer/StringRenderer.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\PromptTemplate\Renderer; + +use Symfony\AI\PromptTemplate\Exception\InvalidArgumentException; + +/** + * Simple string replacement renderer. + * + * Replaces {variable} placeholders with values from the provided array. + * Has zero external dependencies. + * + * @author Johannes Wachter + */ +final readonly class StringRenderer implements RendererInterface +{ + public function render(string $template, array $values): string + { + $result = $template; + + foreach ($values as $key => $value) { + if (!\is_string($key)) { + throw InvalidArgumentException::invalidVariableName(get_debug_type($key)); + } + + if (!\is_string($value) && !is_numeric($value) && !$value instanceof \Stringable) { + throw InvalidArgumentException::invalidVariableValue($key, get_debug_type($value)); + } + + $result = str_replace('{'.$key.'}', (string) $value, $result); + } + + return $result; + } +} diff --git a/src/prompt-template/tests/PromptTemplateTest.php b/src/prompt-template/tests/PromptTemplateTest.php new file mode 100644 index 000000000..67e79aa83 --- /dev/null +++ b/src/prompt-template/tests/PromptTemplateTest.php @@ -0,0 +1,176 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\PromptTemplate\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\PromptTemplate\PromptTemplate; +use Symfony\AI\PromptTemplate\Renderer\ExpressionRenderer; +use Symfony\AI\PromptTemplate\Renderer\RendererInterface; +use Symfony\AI\PromptTemplate\Renderer\StringRenderer; + +/** + * @author Johannes Wachter + */ +final class PromptTemplateTest extends TestCase +{ + public function testConstructorWithDefaultRenderer() + { + $template = new PromptTemplate('Hello {name}!'); + $result = $template->format(['name' => 'World']); + + $this->assertSame('Hello World!', $result); + } + + public function testConstructorWithCustomRenderer() + { + $renderer = new ExpressionRenderer(); + $template = new PromptTemplate('Total: {price * quantity}', $renderer); + $result = $template->format(['price' => 10, 'quantity' => 5]); + + $this->assertSame('Total: 50', $result); + } + + public function testFormatWithEmptyValues() + { + $template = new PromptTemplate('Hello {name}!'); + $result = $template->format([]); + + $this->assertSame('Hello {name}!', $result); + } + + public function testFormatWithMultipleValues() + { + $template = new PromptTemplate('User: {user}, Query: {query}'); + $result = $template->format(['user' => 'Alice', 'query' => 'What is AI?']); + + $this->assertSame('User: Alice, Query: What is AI?', $result); + } + + public function testGetTemplateReturnsOriginalTemplate() + { + $originalTemplate = 'Hello {name}!'; + $template = new PromptTemplate($originalTemplate); + + $this->assertSame($originalTemplate, $template->getTemplate()); + } + + public function testToStringReturnsTemplate() + { + $originalTemplate = 'Hello {name}!'; + $template = new PromptTemplate($originalTemplate); + + $this->assertSame($originalTemplate, (string) $template); + } + + public function testFromStringStaticFactory() + { + $template = PromptTemplate::fromString('Hello {name}!'); + $result = $template->format(['name' => 'World']); + + $this->assertSame('Hello World!', $result); + $this->assertInstanceOf(PromptTemplate::class, $template); + } + + public function testFromStringWithRendererStaticFactory() + { + $renderer = new ExpressionRenderer(); + $template = PromptTemplate::fromStringWithRenderer( + 'Result: {a + b}', + $renderer + ); + $result = $template->format(['a' => 5, 'b' => 3]); + + $this->assertSame('Result: 8', $result); + $this->assertInstanceOf(PromptTemplate::class, $template); + } + + public function testIntegrationWithStringRenderer() + { + $renderer = new StringRenderer(); + $template = new PromptTemplate('Greeting: {greeting}', $renderer); + $result = $template->format(['greeting' => 'Hello World']); + + $this->assertSame('Greeting: Hello World', $result); + } + + public function testIntegrationWithExpressionRenderer() + { + $renderer = new ExpressionRenderer(); + $template = new PromptTemplate('Status: {age >= 18 ? "adult" : "minor"}', $renderer); + $result = $template->format(['age' => 25]); + + $this->assertSame('Status: adult', $result); + } + + public function testCustomRendererImplementation() + { + $customRenderer = new class implements RendererInterface { + public function render(string $template, array $values): string + { + return strtoupper($template); + } + }; + + $template = new PromptTemplate('hello world', $customRenderer); + $result = $template->format([]); + + $this->assertSame('HELLO WORLD', $result); + } + + public function testTemplateIsImmutable() + { + $template = new PromptTemplate('Hello {name}!'); + $result1 = $template->format(['name' => 'Alice']); + $result2 = $template->format(['name' => 'Bob']); + + $this->assertSame('Hello Alice!', $result1); + $this->assertSame('Hello Bob!', $result2); + $this->assertSame('Hello {name}!', $template->getTemplate()); + } + + public function testEmptyTemplate() + { + $template = new PromptTemplate(''); + $result = $template->format(['name' => 'World']); + + $this->assertSame('', $result); + $this->assertSame('', (string) $template); + } + + public function testComplexMultilineTemplate() + { + $templateString = <<<'TEMPLATE' +You are a helpful assistant. + +User: {user} +Query: {query} +Context: {context} +TEMPLATE; + + $template = new PromptTemplate($templateString); + $result = $template->format([ + 'user' => 'Alice', + 'query' => 'What is AI?', + 'context' => 'Education', + ]); + + $expected = <<<'EXPECTED' +You are a helpful assistant. + +User: Alice +Query: What is AI? +Context: Education +EXPECTED; + + $this->assertSame($expected, $result); + } +} diff --git a/src/prompt-template/tests/Renderer/ExpressionRendererTest.php b/src/prompt-template/tests/Renderer/ExpressionRendererTest.php new file mode 100644 index 000000000..1a2aebf4b --- /dev/null +++ b/src/prompt-template/tests/Renderer/ExpressionRendererTest.php @@ -0,0 +1,163 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\PromptTemplate\Tests\Renderer; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\PromptTemplate\Exception\RenderingException; +use Symfony\AI\PromptTemplate\Renderer\ExpressionRenderer; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; + +/** + * @author Johannes Wachter + */ +final class ExpressionRendererTest extends TestCase +{ + private ExpressionRenderer $renderer; + + protected function setUp(): void + { + $this->renderer = new ExpressionRenderer(); + } + + public function testSimpleVariableAccess() + { + $result = $this->renderer->render('Hello {name}!', ['name' => 'World']); + + $this->assertSame('Hello World!', $result); + } + + public function testMathExpressions() + { + $result = $this->renderer->render( + 'Total: {price * quantity}', + ['price' => 10, 'quantity' => 5] + ); + + $this->assertSame('Total: 50', $result); + } + + public function testConditionalExpression() + { + $result = $this->renderer->render( + 'Status: {age >= 18 ? "adult" : "minor"}', + ['age' => 25] + ); + + $this->assertSame('Status: adult', $result); + } + + public function testConditionalExpressionMinor() + { + $result = $this->renderer->render( + 'Status: {age >= 18 ? "adult" : "minor"}', + ['age' => 15] + ); + + $this->assertSame('Status: minor', $result); + } + + public function testArrayAccess() + { + $result = $this->renderer->render( + 'First item: {items[0]}', + ['items' => ['apple', 'banana', 'cherry']] + ); + + $this->assertSame('First item: apple', $result); + } + + public function testObjectPropertyAccess() + { + $user = new class { + public string $name = 'Alice'; + public int $age = 30; + }; + + $result = $this->renderer->render( + 'User: {user.name}, Age: {user.age}', + ['user' => $user] + ); + + $this->assertSame('User: Alice, Age: 30', $result); + } + + public function testMultipleExpressions() + { + $result = $this->renderer->render( + 'Subtotal: {price * quantity}, Tax: {price * quantity * 0.2}', + ['price' => 10, 'quantity' => 5] + ); + + $this->assertSame('Subtotal: 50, Tax: 10', $result); + } + + public function testEmptyTemplate() + { + $result = $this->renderer->render('', ['name' => 'Alice']); + + $this->assertSame('', $result); + } + + public function testTemplateWithNoPlaceholders() + { + $result = $this->renderer->render('Static text', ['name' => 'Alice']); + + $this->assertSame('Static text', $result); + } + + public function testInvalidExpressionThrowsException() + { + $this->expectException(RenderingException::class); + $this->expectExceptionMessage('Failed to render expression'); + + $this->renderer->render('Result: {invalid syntax}', []); + } + + public function testMissingVariableThrowsException() + { + $this->expectException(RenderingException::class); + $this->expectExceptionMessage('Failed to render expression "missing"'); + + $this->renderer->render('Value: {missing}', []); + } + + public function testCustomExpressionLanguageInstance() + { + $expressionLanguage = new ExpressionLanguage(); + $expressionLanguage->register('double', fn ($str) => \sprintf('(%s * 2)', $str), fn (array $values, $value) => $value * 2); + + $renderer = new ExpressionRenderer($expressionLanguage); + $result = $renderer->render('Double: {double(number)}', ['number' => 21]); + + $this->assertSame('Double: 42', $result); + } + + public function testStringConcatenation() + { + $result = $this->renderer->render( + 'Full name: {firstName ~ " " ~ lastName}', + ['firstName' => 'John', 'lastName' => 'Doe'] + ); + + $this->assertSame('Full name: John Doe', $result); + } + + public function testComparisonOperators() + { + $result = $this->renderer->render( + 'Greater: {10 > 5}, Equal: {5 == 5}, Less: {3 < 2}', + [] + ); + + $this->assertSame('Greater: 1, Equal: 1, Less: ', $result); + } +} diff --git a/src/prompt-template/tests/Renderer/StringRendererTest.php b/src/prompt-template/tests/Renderer/StringRendererTest.php new file mode 100644 index 000000000..8bf7986f6 --- /dev/null +++ b/src/prompt-template/tests/Renderer/StringRendererTest.php @@ -0,0 +1,153 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\PromptTemplate\Tests\Renderer; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\PromptTemplate\Exception\InvalidArgumentException; +use Symfony\AI\PromptTemplate\Renderer\StringRenderer; + +/** + * @author Johannes Wachter + */ +final class StringRendererTest extends TestCase +{ + private StringRenderer $renderer; + + protected function setUp(): void + { + $this->renderer = new StringRenderer(); + } + + public function testSimpleSingleVariableReplacement() + { + $result = $this->renderer->render('Hello {name}!', ['name' => 'World']); + + $this->assertSame('Hello World!', $result); + } + + public function testMultipleVariables() + { + $result = $this->renderer->render( + 'User: {user}, Query: {query}', + ['user' => 'Alice', 'query' => 'What is AI?'] + ); + + $this->assertSame('User: Alice, Query: What is AI?', $result); + } + + public function testVariableUsedMultipleTimes() + { + $result = $this->renderer->render( + '{name} said: "Hello, {name}!"', + ['name' => 'Bob'] + ); + + $this->assertSame('Bob said: "Hello, Bob!"', $result); + } + + public function testNumericValues() + { + $result = $this->renderer->render( + 'Price: {price}, Quantity: {quantity}', + ['price' => 10, 'quantity' => 5] + ); + + $this->assertSame('Price: 10, Quantity: 5', $result); + } + + public function testStringableObject() + { + $stringable = new class implements \Stringable { + public function __toString(): string + { + return 'Stringable Object'; + } + }; + + $result = $this->renderer->render('Value: {obj}', ['obj' => $stringable]); + + $this->assertSame('Value: Stringable Object', $result); + } + + public function testMissingVariablesAreLeftUnchanged() + { + $result = $this->renderer->render('Hello {name} and {friend}!', ['name' => 'Alice']); + + $this->assertSame('Hello Alice and {friend}!', $result); + } + + public function testEmptyTemplate() + { + $result = $this->renderer->render('', ['name' => 'Alice']); + + $this->assertSame('', $result); + } + + public function testEmptyValues() + { + $result = $this->renderer->render('Hello {name}!', []); + + $this->assertSame('Hello {name}!', $result); + } + + public function testTemplateWithNoPlaceholders() + { + $result = $this->renderer->render('Static text', ['name' => 'Alice']); + + $this->assertSame('Static text', $result); + } + + public function testSpecialCharactersInValues() + { + $result = $this->renderer->render( + 'Message: {msg}', + ['msg' => 'Special chars: ${}[]()'] + ); + + $this->assertSame('Message: Special chars: ${}[]()', $result); + } + + public function testUnicodeCharacters() + { + $result = $this->renderer->render( + 'Greeting: {greeting}', + ['greeting' => 'こんにちは 世界'] + ); + + $this->assertSame('Greeting: こんにちは 世界', $result); + } + + public function testInvalidVariableNameThrowsException() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Variable name must be a string, "int" given.'); + + // @phpstan-ignore argument.type + $this->renderer->render('Hello {name}!', [0 => 'Alice']); + } + + public function testNonStringableValueThrowsException() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Variable "data" must be a string, numeric, or Stringable, "array" given.'); + + $this->renderer->render('Data: {data}', ['data' => ['foo' => 'bar']]); + } + + public function testObjectWithoutStringableThrowsException() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Variable "obj" must be a string, numeric, or Stringable'); + + $this->renderer->render('Object: {obj}', ['obj' => new \stdClass()]); + } +}