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()]); + } +}