diff --git a/README.md b/README.md index 8c4bb32a..334840cf 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,59 @@ See the [`PromptBuilder` class](https://github.com/WordPress/php-ai-client/blob/ **More documentation is coming soon.** +## Event Dispatching + +The AI Client supports PSR-14 event dispatching for prompt lifecycle events. This allows you to hook into the generation process for logging, monitoring, or other integrations. + +### Available Events + +- `BeforeGenerateResultEvent` - Dispatched before a prompt is sent to the model +- `AfterGenerateResultEvent` - Dispatched after a result is received from the model + +**Important:** Event listeners should not return a value, as they will be ignored. In order to modify data that is passed with the event object, you need to rely on setters on the event object. Any event data for which there are no setters on the event object is meant to be immutable or, in other words, read-only for the event listener. + +### Connecting Your Event Dispatcher + +To enable event dispatching, pass any PSR-14 compatible `EventDispatcherInterface` to the client: + +```php +use WordPress\AiClient\AiClient; + +// Set your PSR-14 event dispatcher +AiClient::setEventDispatcher($yourEventDispatcher); + +// Events will now be dispatched during generation +$text = AiClient::prompt('Hello, world!') + ->generateText(); +``` + +### Example: Logging Events + +```php +use WordPress\AiClient\Events\BeforeGenerateResultEvent; +use WordPress\AiClient\Events\AfterGenerateResultEvent; + +// In your event listener/subscriber +class AiEventListener +{ + public function onBeforeGenerate(BeforeGenerateResultEvent $event): void + { + $model = $event->getModel(); + $messages = $event->getMessages(); + $capability = $event->getCapability(); + + // Log, monitor, or perform other actions + } + + public function onAfterGenerate(AfterGenerateResultEvent $event): void + { + $result = $event->getResult(); + + // Log the result, track usage, etc. + } +} +``` + ## Further reading For more information on the requirements and guiding principles, please review: diff --git a/composer.json b/composer.json index 5ca727a0..a1994147 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,7 @@ "php-http/message-factory": "^1.0", "psr/http-client": "^1.0", "psr/http-factory": "^1.0", + "psr/event-dispatcher": "^1.0", "psr/http-message": "^1.0 || ^2.0" }, "require-dev": { diff --git a/composer.lock b/composer.lock index d6a5af90..519aef77 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "893f04e6854aa23afd7dcd0cabfe1def", + "content-hash": "c290e23e54f97989b8d04bb33bf137fc", "packages": [ { "name": "php-http/discovery", @@ -249,6 +249,56 @@ }, "time": "2024-03-15T13:55:21+00:00" }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, { "name": "psr/http-client", "version": "1.0.3", diff --git a/src/AiClient.php b/src/AiClient.php index ab9ceadd..4af69003 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -4,6 +4,7 @@ namespace WordPress\AiClient; +use Psr\EventDispatcher\EventDispatcherInterface; use WordPress\AiClient\Builders\PromptBuilder; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; @@ -90,6 +91,11 @@ class AiClient */ private static ?ProviderRegistry $defaultRegistry = null; + /** + * @var EventDispatcherInterface|null The event dispatcher for prompt lifecycle events. + */ + private static ?EventDispatcherInterface $eventDispatcher = null; + /** * Gets the default provider registry instance. * @@ -114,6 +120,34 @@ public static function defaultRegistry(): ProviderRegistry return self::$defaultRegistry; } + /** + * Sets the event dispatcher for prompt lifecycle events. + * + * The event dispatcher will be used to dispatch BeforeGenerateResultEvent and + * AfterGenerateResultEvent during prompt generation. + * + * @since n.e.x.t + * + * @param EventDispatcherInterface|null $dispatcher The event dispatcher, or null to disable. + * @return void + */ + public static function setEventDispatcher(?EventDispatcherInterface $dispatcher): void + { + self::$eventDispatcher = $dispatcher; + } + + /** + * Gets the event dispatcher for prompt lifecycle events. + * + * @since n.e.x.t + * + * @return EventDispatcherInterface|null The event dispatcher, or null if not set. + */ + public static function getEventDispatcher(): ?EventDispatcherInterface + { + return self::$eventDispatcher; + } + /** * Checks if a provider is configured and available for use. * @@ -171,7 +205,11 @@ public static function isConfigured($availabilityOrIdOrClassName): bool */ public static function prompt($prompt = null, ?ProviderRegistry $registry = null): PromptBuilder { - return new PromptBuilder($registry ?? self::defaultRegistry(), $prompt); + return new PromptBuilder( + $registry ?? self::defaultRegistry(), + $prompt, + self::$eventDispatcher + ); } /** @@ -225,7 +263,6 @@ public static function generateTextResult( return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateTextResult(); } - /** * Generates an image using the traditional API approach. * diff --git a/src/Builders/PromptBuilder.php b/src/Builders/PromptBuilder.php index 758aeeda..ab5e4e8d 100644 --- a/src/Builders/PromptBuilder.php +++ b/src/Builders/PromptBuilder.php @@ -4,8 +4,11 @@ namespace WordPress\AiClient\Builders; +use Psr\EventDispatcher\EventDispatcherInterface; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; +use WordPress\AiClient\Events\AfterGenerateResultEvent; +use WordPress\AiClient\Events\BeforeGenerateResultEvent; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Messages\DTO\Message; @@ -82,6 +85,11 @@ class PromptBuilder */ protected ?RequestOptions $requestOptions = null; + /** + * @var EventDispatcherInterface|null The event dispatcher for prompt lifecycle events. + */ + private ?EventDispatcherInterface $eventDispatcher = null; + // phpcs:disable Generic.Files.LineLength.TooLong /** * Constructor. @@ -90,12 +98,17 @@ class PromptBuilder * * @param ProviderRegistry $registry The provider registry for finding suitable models. * @param Prompt $prompt Optional initial prompt content. + * @param EventDispatcherInterface|null $eventDispatcher Optional event dispatcher for lifecycle events. */ // phpcs:enable Generic.Files.LineLength.TooLong - public function __construct(ProviderRegistry $registry, $prompt = null) - { + public function __construct( + ProviderRegistry $registry, + $prompt = null, + ?EventDispatcherInterface $eventDispatcher = null + ) { $this->registry = $registry; $this->modelConfig = new ModelConfig(); + $this->eventDispatcher = $eventDispatcher; if ($prompt === null) { return; @@ -826,7 +839,38 @@ public function generateResult(?CapabilityEnum $capability = null): GenerativeAi $model = $this->getConfiguredModel($capability); + // Dispatch BeforeGenerateResultEvent + $this->dispatchEvent( + new BeforeGenerateResultEvent($this->messages, $model, $capability) + ); + // Route to the appropriate generation method based on capability + $result = $this->executeModelGeneration($model, $capability, $this->messages); + + // Dispatch AfterGenerateResultEvent + $this->dispatchEvent( + new AfterGenerateResultEvent($this->messages, $model, $capability, $result) + ); + + return $result; + } + + /** + * Executes the model generation based on capability. + * + * @since n.e.x.t + * + * @param ModelInterface $model The model to use for generation. + * @param CapabilityEnum $capability The capability to use. + * @param list $messages The messages to send. + * @return GenerativeAiResult The generated result. + * @throws RuntimeException If the model doesn't support the required capability. + */ + private function executeModelGeneration( + ModelInterface $model, + CapabilityEnum $capability, + array $messages + ): GenerativeAiResult { if ($capability->isTextGeneration()) { if (!$model instanceof TextGenerationModelInterface) { throw new RuntimeException( @@ -836,7 +880,7 @@ public function generateResult(?CapabilityEnum $capability = null): GenerativeAi ) ); } - return $model->generateTextResult($this->messages); + return $model->generateTextResult($messages); } if ($capability->isImageGeneration()) { @@ -848,7 +892,7 @@ public function generateResult(?CapabilityEnum $capability = null): GenerativeAi ) ); } - return $model->generateImageResult($this->messages); + return $model->generateImageResult($messages); } if ($capability->isTextToSpeechConversion()) { @@ -860,7 +904,7 @@ public function generateResult(?CapabilityEnum $capability = null): GenerativeAi ) ); } - return $model->convertTextToSpeechResult($this->messages); + return $model->convertTextToSpeechResult($messages); } if ($capability->isSpeechGeneration()) { @@ -872,11 +916,11 @@ public function generateResult(?CapabilityEnum $capability = null): GenerativeAi ) ); } - return $model->generateSpeechResult($this->messages); + return $model->generateSpeechResult($messages); } + // Video generation is not yet implemented if ($capability->isVideoGeneration()) { - // Video generation is not yet implemented throw new RuntimeException('Output modality "video" is not yet supported.'); } @@ -1522,4 +1566,19 @@ private function includeOutputModalities(ModalityEnum ...$modalities): void $this->modelConfig->setOutputModalities(array_merge($existing, $toAdd)); } } + + /** + * Dispatches an event if an event dispatcher is registered. + * + * @since n.e.x.t + * + * @param object $event The event to dispatch. + * @return void + */ + private function dispatchEvent(object $event): void + { + if ($this->eventDispatcher !== null) { + $this->eventDispatcher->dispatch($event); + } + } } diff --git a/src/Events/AfterGenerateResultEvent.php b/src/Events/AfterGenerateResultEvent.php new file mode 100644 index 00000000..1cc650f3 --- /dev/null +++ b/src/Events/AfterGenerateResultEvent.php @@ -0,0 +1,111 @@ + The messages that were sent to the model. + */ + private array $messages; + + /** + * @var ModelInterface The model that processed the prompt. + */ + private ModelInterface $model; + + /** + * @var CapabilityEnum|null The capability that was used for generation. + */ + private ?CapabilityEnum $capability; + + /** + * @var GenerativeAiResult The result from the model. + */ + private GenerativeAiResult $result; + + /** + * Constructor. + * + * @since n.e.x.t + * + * @param list $messages The messages that were sent to the model. + * @param ModelInterface $model The model that processed the prompt. + * @param CapabilityEnum|null $capability The capability that was used for generation. + * @param GenerativeAiResult $result The result from the model. + */ + public function __construct( + array $messages, + ModelInterface $model, + ?CapabilityEnum $capability, + GenerativeAiResult $result + ) { + $this->messages = $messages; + $this->model = $model; + $this->capability = $capability; + $this->result = $result; + } + + /** + * Gets the messages that were sent to the model. + * + * @since n.e.x.t + * + * @return list The messages. + */ + public function getMessages(): array + { + return $this->messages; + } + + /** + * Gets the model that processed the prompt. + * + * @since n.e.x.t + * + * @return ModelInterface The model. + */ + public function getModel(): ModelInterface + { + return $this->model; + } + + /** + * Gets the capability that was used for generation. + * + * @since n.e.x.t + * + * @return CapabilityEnum|null The capability, or null if not specified. + */ + public function getCapability(): ?CapabilityEnum + { + return $this->capability; + } + + /** + * Gets the result from the model. + * + * @since n.e.x.t + * + * @return GenerativeAiResult The result. + */ + public function getResult(): GenerativeAiResult + { + return $this->result; + } +} diff --git a/src/Events/BeforeGenerateResultEvent.php b/src/Events/BeforeGenerateResultEvent.php new file mode 100644 index 00000000..c6e27785 --- /dev/null +++ b/src/Events/BeforeGenerateResultEvent.php @@ -0,0 +1,88 @@ + The messages to be sent to the model. + */ + private array $messages; + + /** + * @var ModelInterface The model that will process the prompt. + */ + private ModelInterface $model; + + /** + * @var CapabilityEnum|null The capability being used for generation. + */ + private ?CapabilityEnum $capability; + + /** + * Constructor. + * + * @since n.e.x.t + * + * @param list $messages The messages to be sent to the model. + * @param ModelInterface $model The model that will process the prompt. + * @param CapabilityEnum|null $capability The capability being used for generation. + */ + public function __construct(array $messages, ModelInterface $model, ?CapabilityEnum $capability) + { + $this->messages = $messages; + $this->model = $model; + $this->capability = $capability; + } + + /** + * Gets the messages to be sent to the model. + * + * @since n.e.x.t + * + * @return list The messages. + */ + public function getMessages(): array + { + return $this->messages; + } + + /** + * Gets the model that will process the prompt. + * + * @since n.e.x.t + * + * @return ModelInterface The model. + */ + public function getModel(): ModelInterface + { + return $this->model; + } + + /** + * Gets the capability being used for generation. + * + * @since n.e.x.t + * + * @return CapabilityEnum|null The capability, or null if not specified. + */ + public function getCapability(): ?CapabilityEnum + { + return $this->capability; + } +} diff --git a/tests/mocks/MockEventDispatcher.php b/tests/mocks/MockEventDispatcher.php new file mode 100644 index 00000000..f7e5507c --- /dev/null +++ b/tests/mocks/MockEventDispatcher.php @@ -0,0 +1,98 @@ + The list of dispatched events. + */ + private array $dispatchedEvents = []; + + /** + * @var array> The registered listeners keyed by event class name. + */ + private array $listeners = []; + + /** + * {@inheritDoc} + * + * @param object $event The event to dispatch. + * @return object The event after being processed by listeners. + */ + public function dispatch(object $event): object + { + $this->dispatchedEvents[] = $event; + + $eventClass = get_class($event); + if (isset($this->listeners[$eventClass])) { + foreach ($this->listeners[$eventClass] as $listener) { + $listener($event); + } + } + + return $event; + } + + /** + * Registers a listener for a specific event class. + * + * @param string $eventClass The event class name. + * @param callable $listener The listener callback. + * @return void + */ + public function addListener(string $eventClass, callable $listener): void + { + if (!isset($this->listeners[$eventClass])) { + $this->listeners[$eventClass] = []; + } + $this->listeners[$eventClass][] = $listener; + } + + /** + * Gets all dispatched events. + * + * @return list The dispatched events. + */ + public function getDispatchedEvents(): array + { + return $this->dispatchedEvents; + } + + /** + * Gets dispatched events of a specific type. + * + * @template T of object + * @param class-string $eventClass The event class to filter by. + * @return list The filtered events. + */ + public function getDispatchedEventsOfType(string $eventClass): array + { + return array_values(array_filter( + $this->dispatchedEvents, + static function (object $event) use ($eventClass): bool { + return $event instanceof $eventClass; + } + )); + } + + /** + * Clears all dispatched events. + * + * @return void + */ + public function clearDispatchedEvents(): void + { + $this->dispatchedEvents = []; + } +} diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index 8aef3f0b..5ab0a6a2 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -22,17 +22,6 @@ class AiClientTest extends TestCase { use MockModelCreationTrait; - protected function setUp(): void - { - // Tests use dependency injection - registry instances passed directly to methods - } - - - protected function tearDown(): void - { - // Tests use dependency injection - registry instances passed directly to methods - } - /** * Creates a mock registry that returns empty results for model discovery. * diff --git a/tests/unit/Builders/PromptBuilderEventDispatchingTest.php b/tests/unit/Builders/PromptBuilderEventDispatchingTest.php new file mode 100644 index 00000000..092a9330 --- /dev/null +++ b/tests/unit/Builders/PromptBuilderEventDispatchingTest.php @@ -0,0 +1,159 @@ +registry = new ProviderRegistry(); + $this->registry->registerProvider(MockProvider::class); + $this->dispatcher = new MockEventDispatcher(); + } + + /** + * Tests that events are dispatched when a dispatcher is injected. + * + * @return void + */ + public function testEventsAreDispatchedWhenDispatcherIsInjected(): void + { + $result = $this->createTestResult(); + $model = $this->createMockTextGenerationModel($result); + + $builder = new PromptBuilder($this->registry, 'Hello, world!', $this->dispatcher); + $builder->usingModel($model); + + $builder->generateTextResult(); + + $beforeEvents = $this->dispatcher->getDispatchedEventsOfType(BeforeGenerateResultEvent::class); + $afterEvents = $this->dispatcher->getDispatchedEventsOfType(AfterGenerateResultEvent::class); + + $this->assertCount(1, $beforeEvents); + $this->assertCount(1, $afterEvents); + } + + /** + * Tests that no events are dispatched when dispatcher is not set. + * + * @return void + */ + public function testNoEventsDispatchedWithoutDispatcher(): void + { + $result = $this->createTestResult(); + $model = $this->createMockTextGenerationModel($result); + + $builder = new PromptBuilder($this->registry, 'Hello, world!'); + $builder->usingModel($model); + + $builder->generateTextResult(); + + // No dispatcher set, so no events should be dispatched + $this->assertCount(0, $this->dispatcher->getDispatchedEvents()); + } + + /** + * Tests that BeforeGenerateResultEvent contains correct data. + * + * @return void + */ + public function testBeforeGenerateResultEventContainsCorrectData(): void + { + $result = $this->createTestResult(); + $model = $this->createMockTextGenerationModel($result); + + $builder = new PromptBuilder($this->registry, 'Test prompt', $this->dispatcher); + $builder->usingModel($model); + + $builder->generateTextResult(); + + $beforeEvents = $this->dispatcher->getDispatchedEventsOfType(BeforeGenerateResultEvent::class); + $this->assertCount(1, $beforeEvents); + + $event = $beforeEvents[0]; + $this->assertCount(1, $event->getMessages()); + $this->assertSame($model, $event->getModel()); + $this->assertEquals(CapabilityEnum::textGeneration(), $event->getCapability()); + } + + /** + * Tests that AfterGenerateResultEvent contains correct data. + * + * @return void + */ + public function testAfterGenerateResultEventContainsCorrectData(): void + { + $result = $this->createTestResult('Generated response'); + $model = $this->createMockTextGenerationModel($result); + + $builder = new PromptBuilder($this->registry, 'Test prompt', $this->dispatcher); + $builder->usingModel($model); + + $returnedResult = $builder->generateTextResult(); + + $afterEvents = $this->dispatcher->getDispatchedEventsOfType(AfterGenerateResultEvent::class); + $this->assertCount(1, $afterEvents); + + $event = $afterEvents[0]; + $this->assertCount(1, $event->getMessages()); + $this->assertSame($model, $event->getModel()); + $this->assertEquals(CapabilityEnum::textGeneration(), $event->getCapability()); + $this->assertSame($result, $event->getResult()); + $this->assertSame($returnedResult, $event->getResult()); + } + + /** + * Tests that events are dispatched in correct order. + * + * @return void + */ + public function testEventsDispatchedInCorrectOrder(): void + { + $result = $this->createTestResult(); + $model = $this->createMockTextGenerationModel($result); + + $builder = new PromptBuilder($this->registry, 'Hello', $this->dispatcher); + $builder->usingModel($model); + + $builder->generateTextResult(); + + $events = $this->dispatcher->getDispatchedEvents(); + $this->assertCount(2, $events); + $this->assertInstanceOf(BeforeGenerateResultEvent::class, $events[0]); + $this->assertInstanceOf(AfterGenerateResultEvent::class, $events[1]); + } +} diff --git a/tests/unit/Events/AfterPromptSentEventTest.php b/tests/unit/Events/AfterPromptSentEventTest.php new file mode 100644 index 00000000..f8601611 --- /dev/null +++ b/tests/unit/Events/AfterPromptSentEventTest.php @@ -0,0 +1,84 @@ +createTestResult('Hello!'); + $model = $this->createMockTextGenerationModel($result); + $capability = CapabilityEnum::textGeneration(); + + $event = new AfterGenerateResultEvent($messages, $model, $capability, $result); + + $this->assertSame($messages, $event->getMessages()); + $this->assertSame($model, $event->getModel()); + $this->assertSame($capability, $event->getCapability()); + $this->assertSame($result, $event->getResult()); + } + + /** + * Tests event construction with null capability. + * + * @return void + */ + public function testConstructionWithNullCapability(): void + { + $messages = [ + new UserMessage([new MessagePart('Hello')]) + ]; + $result = $this->createTestResult('Response'); + $model = $this->createMockTextGenerationModel($result); + + $event = new AfterGenerateResultEvent($messages, $model, null, $result); + + $this->assertNull($event->getCapability()); + } + + /** + * Tests that result is accessible. + * + * @return void + */ + public function testGetResult(): void + { + $messages = [ + new UserMessage([new MessagePart('Test prompt')]) + ]; + $result = $this->createTestResult('Test response'); + $model = $this->createMockTextGenerationModel($result); + + $event = new AfterGenerateResultEvent( + $messages, + $model, + CapabilityEnum::textGeneration(), + $result + ); + + $this->assertSame($result, $event->getResult()); + $this->assertCount(1, $event->getResult()->getCandidates()); + } +} diff --git a/tests/unit/Events/BeforePromptSentEventTest.php b/tests/unit/Events/BeforePromptSentEventTest.php new file mode 100644 index 00000000..8163d61e --- /dev/null +++ b/tests/unit/Events/BeforePromptSentEventTest.php @@ -0,0 +1,79 @@ +createTestResult(); + $model = $this->createMockTextGenerationModel($result); + $capability = CapabilityEnum::textGeneration(); + + $event = new BeforeGenerateResultEvent($messages, $model, $capability); + + $this->assertSame($messages, $event->getMessages()); + $this->assertSame($model, $event->getModel()); + $this->assertSame($capability, $event->getCapability()); + } + + /** + * Tests event construction with null capability. + * + * @return void + */ + public function testConstructionWithNullCapability(): void + { + $messages = [ + new UserMessage([new MessagePart('Hello')]) + ]; + $result = $this->createTestResult(); + $model = $this->createMockTextGenerationModel($result); + + $event = new BeforeGenerateResultEvent($messages, $model, null); + + $this->assertNull($event->getCapability()); + } + + /** + * Tests that the event can hold multiple messages. + * + * @return void + */ + public function testMultipleMessages(): void + { + $messages = [ + new UserMessage([new MessagePart('First message')]), + new UserMessage([new MessagePart('Second message')]), + new UserMessage([new MessagePart('Third message')]) + ]; + $result = $this->createTestResult(); + $model = $this->createMockTextGenerationModel($result); + + $event = new BeforeGenerateResultEvent($messages, $model, CapabilityEnum::textGeneration()); + + $this->assertCount(3, $event->getMessages()); + } +}