diff --git a/README.md b/README.md index c6a141d3..7c57454c 100644 --- a/README.md +++ b/README.md @@ -769,6 +769,82 @@ final class MyProcessor implements OutputProcessorInterface, ChainAwareInterface } ``` +### Fabric Patterns + +LLM Chain supports [Fabric](https://github.com/danielmiessler/fabric), a popular collection of system prompts from the AI community. These patterns provide pre-built, tested prompts for common tasks like summarization, analysis, and content creation. + +> [!NOTE] +> Using Fabric patterns requires the `php-llm/fabric-pattern` package to be installed separately. + +#### Installation + +```bash +composer require php-llm/fabric-pattern +``` + +#### Usage with Message Factory + +The simplest way to use Fabric patterns is through the `Message::fabric()` factory method: + +```php +use PhpLlm\LlmChain\Platform\Message\Message; +use PhpLlm\LlmChain\Platform\Message\MessageBag; + +$messages = new MessageBag( + Message::fabric('create_summary'), + Message::ofUser($articleContent) +); + +$response = $chain->call($messages); +``` + +#### Usage with Input Processor + +For more flexibility, you can use the `FabricInputProcessor` to dynamically load patterns: + +```php +use PhpLlm\LlmChain\Chain\Chain; +use PhpLlm\LlmChain\Platform\Fabric\FabricInputProcessor; + +// Initialize Platform and LLM + +$processor = new FabricInputProcessor(); +$chain = new Chain($platform, $model, [$processor]); + +$messages = new MessageBag( + Message::ofUser('Analyze this article for potential security issues: ...') +); + +// Use any Fabric pattern via options +$response = $chain->call($messages, ['fabric_pattern' => 'analyze_threat_report']); +``` + +#### Custom Pattern Locations + +If you have your own collection of patterns or want to use a local copy: + +```php +use PhpLlm\LlmChain\Platform\Fabric\FabricRepository; + +// Use custom pattern directory +$repository = new FabricRepository('/path/to/custom/patterns'); +$processor = new FabricInputProcessor($repository); + +// Or with the factory method +$message = Message::fabric('my_custom_pattern', '/path/to/custom/patterns'); +``` + +#### Available Patterns + +Some popular Fabric patterns include: +- `create_summary` - Create a comprehensive summary +- `analyze_claims` - Analyze and fact-check claims +- `extract_wisdom` - Extract key insights and wisdom +- `improve_writing` - Improve writing quality and clarity +- `create_quiz` - Generate quiz questions from content + +For a full list of available patterns, visit the [Fabric patterns directory](https://github.com/danielmiessler/fabric/tree/main/patterns). + ## HuggingFace LLM Chain comes out of the box with an integration for [HuggingFace](https://huggingface.co/) which is a platform for diff --git a/composer.json b/composer.json index 24604bc6..a915ff48 100644 --- a/composer.json +++ b/composer.json @@ -46,6 +46,7 @@ "mrmysql/youtube-transcript": "^v0.0.5", "php-cs-fixer/shim": "^3.70", "php-http/discovery": "^1.20", + "php-llm/fabric-pattern": "^0.1", "phpstan/phpstan": "^2.0", "phpstan/phpstan-symfony": "^2.0", "phpstan/phpstan-webmozart-assert": "^2.0", @@ -70,6 +71,7 @@ "doctrine/dbal": "For using MariaDB via Doctrine as retrieval vector store", "mongodb/mongodb": "For using MongoDB Atlas as retrieval vector store.", "mrmysql/youtube-transcript": "For using the YouTube transcription tool.", + "php-llm/fabric-pattern": "For using Fabric patterns - a collection of pre-built system prompts.", "probots-io/pinecone-php": "For using the Pinecone as retrieval vector store.", "symfony/dom-crawler": "For using the Crawler tool." }, diff --git a/examples/fabric/summarize.php b/examples/fabric/summarize.php new file mode 100644 index 00000000..3aa31721 --- /dev/null +++ b/examples/fabric/summarize.php @@ -0,0 +1,55 @@ +call($messages); + +echo 'Summary using Fabric pattern "create_summary":'.\PHP_EOL; +echo '=============================================='.\PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/fabric/with-processor.php b/examples/fabric/with-processor.php new file mode 100644 index 00000000..dc5020da --- /dev/null +++ b/examples/fabric/with-processor.php @@ -0,0 +1,56 @@ +call($messages, ['fabric_pattern' => 'analyze_code']); + +echo 'Code Analysis using Fabric pattern "analyze_code":'.\PHP_EOL; +echo '=================================================='.\PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/src/Platform/Fabric/Exception/PatternNotFoundException.php b/src/Platform/Fabric/Exception/PatternNotFoundException.php new file mode 100644 index 00000000..8b4c2142 --- /dev/null +++ b/src/Platform/Fabric/Exception/PatternNotFoundException.php @@ -0,0 +1,12 @@ +getOptions(); + + if (!\array_key_exists('fabric_pattern', $options)) { + return; + } + + $pattern = $options['fabric_pattern']; + if (!\is_string($pattern)) { + throw new \InvalidArgumentException('The "fabric_pattern" option must be a string'); + } + + // Load the pattern and prepend as system message + $fabricPrompt = $this->repository->load($pattern); + $systemMessage = new SystemMessage($fabricPrompt->getContent()); + + // Prepend the system message + $input->messages = $input->messages->prepend($systemMessage); + + // Remove the fabric option from the chain options + unset($options['fabric_pattern']); + $input->setOptions($options); + } +} diff --git a/src/Platform/Fabric/FabricPrompt.php b/src/Platform/Fabric/FabricPrompt.php new file mode 100644 index 00000000..6d28303b --- /dev/null +++ b/src/Platform/Fabric/FabricPrompt.php @@ -0,0 +1,40 @@ + $metadata + */ + public function __construct( + private string $pattern, + private string $content, + private array $metadata = [], + ) { + } + + /** + * @return non-empty-string + */ + public function getPattern(): string + { + return $this->pattern; + } + + public function getContent(): string + { + return $this->content; + } + + public function getMetadata(): array + { + return $this->metadata; + } +} diff --git a/src/Platform/Fabric/FabricPromptInterface.php b/src/Platform/Fabric/FabricPromptInterface.php new file mode 100644 index 00000000..4406e8ef --- /dev/null +++ b/src/Platform/Fabric/FabricPromptInterface.php @@ -0,0 +1,30 @@ + + */ + public function getMetadata(): array; +} diff --git a/src/Platform/Fabric/FabricRepository.php b/src/Platform/Fabric/FabricRepository.php new file mode 100644 index 00000000..4216dca7 --- /dev/null +++ b/src/Platform/Fabric/FabricRepository.php @@ -0,0 +1,136 @@ + + */ + private array $cache = []; + + public function __construct(?string $patternsPath = null) + { + if (null === $patternsPath) { + // Check if fabric-pattern package is installed + $fabricPatternPath = \dirname(__DIR__, 4).'/fabric-pattern/patterns'; + if (is_dir($fabricPatternPath)) { + $this->patternsPath = $fabricPatternPath; + } else { + throw new \RuntimeException('Fabric patterns not found. Please install the "php-llm/fabric-pattern" package: composer require php-llm/fabric-pattern'); + } + } else { + $this->patternsPath = $patternsPath; + } + } + + /** + * Load a Fabric pattern by name. + * + * @throws PatternNotFoundException + */ + public function load(string $pattern): FabricPromptInterface + { + if (isset($this->cache[$pattern])) { + return $this->cache[$pattern]; + } + + $patternPath = $this->patternsPath.'/'.$pattern; + $systemFile = $patternPath.'/system.md'; + + if (!is_dir($patternPath) || !is_file($systemFile)) { + throw new PatternNotFoundException(\sprintf('Pattern "%s" not found at path "%s"', $pattern, $patternPath)); + } + + $content = file_get_contents($systemFile); + if (false === $content) { + throw new PatternNotFoundException(\sprintf('Could not read system.md for pattern "%s"', $pattern)); + } + + $metadata = $this->loadMetadata($patternPath); + + return $this->cache[$pattern] = new FabricPrompt($pattern, $content, $metadata); + } + + /** + * List all available patterns. + * + * @return string[] + */ + public function listPatterns(): array + { + if (!is_dir($this->patternsPath)) { + return []; + } + + $patterns = []; + $directories = scandir($this->patternsPath); + + if (false === $directories) { + return []; + } + + foreach ($directories as $dir) { + if ('.' === $dir || '..' === $dir) { + continue; + } + + $systemFile = $this->patternsPath.'/'.$dir.'/system.md'; + if (is_dir($this->patternsPath.'/'.$dir) && is_file($systemFile)) { + $patterns[] = $dir; + } + } + + sort($patterns); + + return $patterns; + } + + /** + * Check if a pattern exists. + */ + public function exists(string $pattern): bool + { + $patternPath = $this->patternsPath.'/'.$pattern; + $systemFile = $patternPath.'/system.md'; + + return is_dir($patternPath) && is_file($systemFile); + } + + /** + * @return array + */ + private function loadMetadata(string $patternPath): array + { + $metadata = []; + + // Check for README.md + $readmeFile = $patternPath.'/README.md'; + if (is_file($readmeFile)) { + $metadata['readme'] = file_get_contents($readmeFile) ?: ''; + } + + // Check for other metadata files + $metadataFile = $patternPath.'/metadata.json'; + if (is_file($metadataFile)) { + $jsonContent = file_get_contents($metadataFile); + if (false !== $jsonContent) { + $decoded = json_decode($jsonContent, true); + if (\is_array($decoded)) { + $metadata = array_merge($metadata, $decoded); + } + } + } + + return $metadata; + } +} diff --git a/src/Platform/Message/Message.php b/src/Platform/Message/Message.php index f15ad17b..d3972fff 100644 --- a/src/Platform/Message/Message.php +++ b/src/Platform/Message/Message.php @@ -4,6 +4,7 @@ namespace PhpLlm\LlmChain\Platform\Message; +use PhpLlm\LlmChain\Platform\Fabric\FabricRepository; use PhpLlm\LlmChain\Platform\Message\Content\ContentInterface; use PhpLlm\LlmChain\Platform\Message\Content\Text; use PhpLlm\LlmChain\Platform\Response\ToolCall; @@ -24,6 +25,23 @@ public static function forSystem(\Stringable|string $content): SystemMessage return new SystemMessage($content instanceof \Stringable ? (string) $content : $content); } + /** + * Create a SystemMessage from a Fabric pattern. + * + * Requires the "php-llm/fabric-pattern" package to be installed. + * + * @param string|null $patternsPath Optional custom patterns path + * + * @throws \RuntimeException if fabric-pattern package is not installed + */ + public static function fabric(string $pattern, ?string $patternsPath = null): SystemMessage + { + $repository = new FabricRepository($patternsPath); + $fabricPrompt = $repository->load($pattern); + + return new SystemMessage($fabricPrompt->getContent()); + } + /** * @param ?ToolCall[] $toolCalls */ diff --git a/tests/Platform/Fabric/FabricInputProcessorTest.php b/tests/Platform/Fabric/FabricInputProcessorTest.php new file mode 100644 index 00000000..32791ffa --- /dev/null +++ b/tests/Platform/Fabric/FabricInputProcessorTest.php @@ -0,0 +1,148 @@ +createMock(FabricRepository::class); + $repository->expects($this->once()) + ->method('load') + ->with('test_pattern') + ->willReturn(new FabricPrompt('test_pattern', '# Fabric Content')); + + $processor = new FabricInputProcessor($repository); + + $messages = new MessageBag(); + $model = new Model('test-model', []); + $input = new Input($model, $messages, ['fabric_pattern' => 'test_pattern']); + + $processor->processInput($input); + + self::assertCount(1, $input->messages); + $messages = $input->messages->getMessages(); + self::assertInstanceOf(SystemMessage::class, $messages[0]); + self::assertSame('# Fabric Content', $messages[0]->content); + self::assertArrayNotHasKey('fabric_pattern', $input->getOptions()); + } + + #[Test] + public function processInputWithoutFabricPattern(): void + { + $repository = $this->createMock(FabricRepository::class); + $repository->expects($this->never())->method('load'); + + $processor = new FabricInputProcessor($repository); + + $messages = new MessageBag(); + $model = new Model('test-model', []); + $input = new Input($model, $messages, ['temperature' => 0.7]); + + $processor->processInput($input); + + self::assertCount(0, $input->messages); + self::assertSame(['temperature' => 0.7], $input->getOptions()); + } + + #[Test] + public function processInputWithDefaultRepositoryThrowsExceptionWhenPackageNotInstalled(): void + { + $processor = new FabricInputProcessor(); + + $messages = new MessageBag(); + $model = new Model('test-model', []); + $input = new Input($model, $messages, ['fabric_pattern' => 'test_pattern']); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Fabric patterns not found. Please install the "php-llm/fabric-pattern" package'); + + $processor->processInput($input); + } + + #[Test] + public function processInputWithCustomPatternsPath(): void + { + $testPath = sys_get_temp_dir().'/fabric-test-'.uniqid(); + mkdir($testPath.'/test_pattern', 0777, true); + file_put_contents($testPath.'/test_pattern/system.md', '# Custom Path Content'); + + $repository = new FabricRepository($testPath); + $processor = new FabricInputProcessor($repository); + + $messages = new MessageBag(); + $model = new Model('test-model', []); + + $input = new Input($model, $messages, [ + 'fabric_pattern' => 'test_pattern', + ]); + + try { + $processor->processInput($input); + + self::assertCount(1, $input->messages); + $messages = $input->messages->getMessages(); + self::assertInstanceOf(SystemMessage::class, $messages[0]); + self::assertSame('# Custom Path Content', $messages[0]->content); + self::assertArrayNotHasKey('fabric_pattern', $input->getOptions()); + } finally { + // Cleanup + unlink($testPath.'/test_pattern/system.md'); + rmdir($testPath.'/test_pattern'); + rmdir($testPath); + } + } + + #[Test] + public function processInputWithInvalidPatternType(): void + { + $processor = new FabricInputProcessor(); + + $messages = new MessageBag(); + $model = new Model('test-model', []); + $input = new Input($model, $messages, ['fabric_pattern' => 123]); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The "fabric_pattern" option must be a string'); + + $processor->processInput($input); + } + + #[Test] + public function processInputPatternNotFound(): void + { + $repository = $this->createMock(FabricRepository::class); + $repository->expects($this->once()) + ->method('load') + ->with('non_existing') + ->willThrowException(new PatternNotFoundException('Pattern not found')); + + $processor = new FabricInputProcessor($repository); + + $messages = new MessageBag(); + $model = new Model('test-model'); + $input = new Input($model, $messages, ['fabric_pattern' => 'non_existing']); + + $this->expectException(PatternNotFoundException::class); + + $processor->processInput($input); + } +} diff --git a/tests/Platform/Fabric/FabricPromptTest.php b/tests/Platform/Fabric/FabricPromptTest.php new file mode 100644 index 00000000..961541e3 --- /dev/null +++ b/tests/Platform/Fabric/FabricPromptTest.php @@ -0,0 +1,40 @@ + 'Test Author'] + ); + + self::assertSame('test_pattern', $prompt->getPattern()); + self::assertSame('# Test Content', $prompt->getContent()); + self::assertSame(['author' => 'Test Author'], $prompt->getMetadata()); + } + + #[Test] + public function constructionWithoutMetadata(): void + { + $prompt = new FabricPrompt('test_pattern', '# Test Content'); + + self::assertSame('test_pattern', $prompt->getPattern()); + self::assertSame('# Test Content', $prompt->getContent()); + self::assertSame([], $prompt->getMetadata()); + } +} diff --git a/tests/Platform/Fabric/FabricRepositoryTest.php b/tests/Platform/Fabric/FabricRepositoryTest.php new file mode 100644 index 00000000..7652013c --- /dev/null +++ b/tests/Platform/Fabric/FabricRepositoryTest.php @@ -0,0 +1,171 @@ +testPatternsPath = sys_get_temp_dir().'/fabric-test-'.uniqid(); + mkdir($this->testPatternsPath); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->testPatternsPath); + } + + #[Test] + public function constructorThrowsExceptionWhenPackageNotInstalled(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Fabric patterns not found. Please install the "php-llm/fabric-pattern" package'); + + new FabricRepository(); + } + + #[Test] + public function loadExistingPattern(): void + { + $this->createTestPattern('test_pattern', '# Test Pattern Content'); + + $repository = new FabricRepository($this->testPatternsPath); + $prompt = $repository->load('test_pattern'); + + self::assertInstanceOf(FabricPrompt::class, $prompt); + self::assertSame('test_pattern', $prompt->getPattern()); + self::assertSame('# Test Pattern Content', $prompt->getContent()); + self::assertSame([], $prompt->getMetadata()); + } + + #[Test] + public function loadPatternWithMetadata(): void + { + $this->createTestPattern('test_pattern', '# Test Pattern Content'); + file_put_contents( + $this->testPatternsPath.'/test_pattern/README.md', + 'This is a readme' + ); + file_put_contents( + $this->testPatternsPath.'/test_pattern/metadata.json', + json_encode(['author' => 'Test Author', 'version' => '1.0']) + ); + + $repository = new FabricRepository($this->testPatternsPath); + $prompt = $repository->load('test_pattern'); + + $metadata = $prompt->getMetadata(); + self::assertSame('This is a readme', $metadata['readme']); + self::assertSame('Test Author', $metadata['author']); + self::assertSame('1.0', $metadata['version']); + } + + #[Test] + public function loadNonExistingPattern(): void + { + $repository = new FabricRepository($this->testPatternsPath); + + $this->expectException(PatternNotFoundException::class); + $this->expectExceptionMessage('Pattern "non_existing" not found'); + + $repository->load('non_existing'); + } + + #[Test] + public function loadUsesCache(): void + { + $this->createTestPattern('cached_pattern', 'Original content'); + + $repository = new FabricRepository($this->testPatternsPath); + $prompt1 = $repository->load('cached_pattern'); + + // Change the file content + file_put_contents( + $this->testPatternsPath.'/cached_pattern/system.md', + 'Changed content' + ); + + // Should still return cached version + $prompt2 = $repository->load('cached_pattern'); + self::assertSame($prompt1, $prompt2); + self::assertSame('Original content', $prompt2->getContent()); + } + + #[Test] + public function listPatterns(): void + { + $this->createTestPattern('pattern_a', 'Content A'); + $this->createTestPattern('pattern_b', 'Content B'); + $this->createTestPattern('pattern_c', 'Content C'); + mkdir($this->testPatternsPath.'/invalid_pattern'); // No system.md + + $repository = new FabricRepository($this->testPatternsPath); + $patterns = $repository->listPatterns(); + + self::assertSame(['pattern_a', 'pattern_b', 'pattern_c'], $patterns); + } + + #[Test] + public function listPatternsEmptyDirectory(): void + { + $repository = new FabricRepository($this->testPatternsPath); + $patterns = $repository->listPatterns(); + + self::assertSame([], $patterns); + } + + #[Test] + public function listPatternsNonExistingDirectory(): void + { + $repository = new FabricRepository('/non/existing/path'); + $patterns = $repository->listPatterns(); + + self::assertSame([], $patterns); + } + + #[Test] + public function exists(): void + { + $this->createTestPattern('existing_pattern', 'Content'); + + $repository = new FabricRepository($this->testPatternsPath); + + self::assertTrue($repository->exists('existing_pattern')); + self::assertFalse($repository->exists('non_existing')); + } + + private function createTestPattern(string $pattern, string $content): void + { + $patternPath = $this->testPatternsPath.'/'.$pattern; + mkdir($patternPath, 0777, true); + file_put_contents($patternPath.'/system.md', $content); + } + + private function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $files = array_diff(scandir($dir) ?: [], ['.', '..']); + foreach ($files as $file) { + $path = $dir.'/'.$file; + is_dir($path) ? $this->removeDirectory($path) : unlink($path); + } + rmdir($dir); + } +} diff --git a/tests/Platform/Message/MessageFabricTest.php b/tests/Platform/Message/MessageFabricTest.php new file mode 100644 index 00000000..f850adb4 --- /dev/null +++ b/tests/Platform/Message/MessageFabricTest.php @@ -0,0 +1,55 @@ +testPatternsPath = sys_get_temp_dir().'/fabric-test-'.uniqid(); + mkdir($this->testPatternsPath.'/test_pattern', 0777, true); + file_put_contents( + $this->testPatternsPath.'/test_pattern/system.md', + '# Test Fabric Pattern' + ); + } + + protected function tearDown(): void + { + unlink($this->testPatternsPath.'/test_pattern/system.md'); + rmdir($this->testPatternsPath.'/test_pattern'); + rmdir($this->testPatternsPath); + } + + #[Test] + public function fabricMethodCreatesSystemMessage(): void + { + $message = Message::fabric('test_pattern', $this->testPatternsPath); + + self::assertInstanceOf(SystemMessage::class, $message); + self::assertSame('# Test Fabric Pattern', $message->content); + } + + #[Test] + public function fabricMethodThrowsExceptionForNonExistingPattern(): void + { + $this->expectException(PatternNotFoundException::class); + $this->expectExceptionMessage('Pattern "non_existing" not found'); + + Message::fabric('non_existing', $this->testPatternsPath); + } +}