diff --git a/.cspell-project-words.txt b/.cspell-project-words.txt index 1452e805a824c32a4f6580920ad8b8db67b325fc..a0e21477cbd1e8277ab427467d62b2f747f5cc6c 100644 --- a/.cspell-project-words.txt +++ b/.cspell-project-words.txt @@ -124,6 +124,7 @@ octect octocat oden oneshot +operationtype outro overriden paranthesis diff --git a/src/AiProviderPluginManager.php b/src/AiProviderPluginManager.php index ecfbe866199edff7441cdce63a51a58f4d7da29a..917d0c8995e21d85f7bdb0031cca99dd658e3ba4 100644 --- a/src/AiProviderPluginManager.php +++ b/src/AiProviderPluginManager.php @@ -275,30 +275,29 @@ final class AiProviderPluginManager extends DefaultPluginManager { if (!empty($data->data)) { return $data->data; } - // Look in the OperationType/** directories. - $operation_types = []; - $base_path = $this->moduleHandler->getModule('ai')->getPath() . '/src/OperationType'; - $directories = new \RecursiveDirectoryIterator($base_path); - $iterator = new \RecursiveIteratorIterator($directories); - $regex = new \RegexIterator($iterator, '/^.+\.php$/i', \RecursiveRegexIterator::GET_MATCH); - foreach ($regex as $file) { - $interface = $this->getInterfaceFromFile($file[0]); - if ($interface && $this->doesInterfaceExtend($interface, OperationTypeInterface::class)) { - $reflection = new \ReflectionClass($interface); - $attributes = $reflection->getAttributes(OperationType::class); - foreach ($attributes as $attribute) { - $operation_types[] = [ - 'id' => $attribute->newInstance()->id, - 'label' => $attribute->newInstance()->label->render(), - ]; - } + + // We use a plugin manager only to discover the possible operation types. + $manager = new class( + 'OperationType', + $this->namespaces, + $this->moduleHandler, + OperationTypeInterface::class, + OperationType::class + ) extends DefaultPluginManager { + + /** + * Call protected methods usually called in the constructor. + */ + public function finishSetup(): void { + $this->alterInfo('ai_operationtype'); } - } - // Save to cache. - $this->cacheBackend->set('ai_operation_types', $operation_types); + }; + $manager->finishSetup(); + // The in situ plugin manager uses the same cache backend. + $manager->setCacheBackend($this->cacheBackend, 'ai_operation_types'); - return $operation_types; + return $manager->getDefinitions(); } /** @@ -369,58 +368,6 @@ final class AiProviderPluginManager extends DefaultPluginManager { return TRUE; } - /** - * Extracts the fully qualified interface name from a file. - * - * @param string $file - * The file path. - * - * @return string|null - * The fully qualified interface name, or NULL if not found. - */ - protected function getInterfaceFromFile($file) { - $contents = file_get_contents($file); - - // Match namespace and interface declarations. - if (preg_match('/namespace\s+([^;]+);/i', $contents, $matches)) { - $namespace = $matches[1]; - } - - // Match on starts with interface and has extends in it. - if (preg_match('/interface\s+([^ ]+)\s+extends\s+([^ ]+)/i', $contents, $matches) && isset($namespace)) { - $interface = $matches[1]; - return $namespace . '\\' . $interface; - } - - return NULL; - } - - /** - * Checks if an interface extends another interface. - * - * @param string $interface - * The interface name. - * @param string $baseInterface - * The base interface name. - * - * @return bool - * TRUE if the interface extends the base interface, FALSE otherwise. - */ - protected function doesInterfaceExtend($interface, $baseInterface) { - try { - $reflection = new \ReflectionClass($interface); - - if ($reflection->isInterface() && in_array($baseInterface, $reflection->getInterfaceNames())) { - return TRUE; - } - } - catch (\ReflectionException $e) { - // Ignore. - } - - return FALSE; - } - /** * Gives notice that a provider is disabled. * diff --git a/tests/modules/ai_test/ai_test.module b/tests/modules/ai_test/ai_test.module new file mode 100644 index 0000000000000000000000000000000000000000..55ab09338d924717c1e8455f4f6d435207ef8230 --- /dev/null +++ b/tests/modules/ai_test/ai_test.module @@ -0,0 +1,17 @@ +<?php + +/** + * @file + * AI test module file. + */ + +use Drupal\Core\Hook\Attribute\LegacyHook; +use Drupal\ai_test\Hook\OperationTypeHook; + +/** + * Implements hook_ai_operationtype_alter(). + */ +#[LegacyHook] +function ai_test_ai_operationtype_alter(array &$operation_types) { + \Drupal::service(OperationTypeHook::class)->echoOperationType($operation_types); +} diff --git a/tests/modules/ai_test/ai_test.services.yml b/tests/modules/ai_test/ai_test.services.yml new file mode 100644 index 0000000000000000000000000000000000000000..d6fa111b46740a96cafa00fbd37570c9f5ecb89c --- /dev/null +++ b/tests/modules/ai_test/ai_test.services.yml @@ -0,0 +1,4 @@ +services: + Drupal\ai_test\Hook\OperationTypeHook: + class: Drupal\ai_test\Hook\OperationTypeHook + autowire: true diff --git a/tests/modules/ai_test/src/Hook/OperationTypeHook.php b/tests/modules/ai_test/src/Hook/OperationTypeHook.php new file mode 100644 index 0000000000000000000000000000000000000000..2e5bbca8e84af9d1ef61af1e455e37dfc913dd59 --- /dev/null +++ b/tests/modules/ai_test/src/Hook/OperationTypeHook.php @@ -0,0 +1,20 @@ +<?php + +namespace Drupal\ai_test\Hook; + +use Drupal\Core\Hook\Attribute\Hook; + +/** + * Hooks to interact with operation types. + */ +class OperationTypeHook { + + /** + * Implements hook_ai_operationtype_alter(). + */ + #[Hook('ai_operationtype_alter')] + public function echoOperationType(array &$operation_types) { + $operation_types['echo']['label'] = 'Echo altered'; + } + +} diff --git a/tests/modules/ai_test/src/OperationType/Echo/EchoInput.php b/tests/modules/ai_test/src/OperationType/Echo/EchoInput.php new file mode 100644 index 0000000000000000000000000000000000000000..26de696dd56cff13dd088daf3c4b278eba7eef9c --- /dev/null +++ b/tests/modules/ai_test/src/OperationType/Echo/EchoInput.php @@ -0,0 +1,58 @@ +<?php + +namespace Drupal\ai_test\OperationType\Echo; + +use Drupal\ai\OperationType\InputInterface; + +/** + * Input object for echo operations. + */ +class EchoInput implements InputInterface { + + /** + * The constructor. + * + * @param string $input + * The echo input. + */ + public function __construct(private string $input) {} + + /** + * {@inheritdoc} + */ + public function toString(): string { + return $this->input; + } + + /** + * {@inheritdoc} + */ + public function getDebugData(): array { + return []; + } + + /** + * {@inheritdoc} + */ + public function setDebugData(array $debugData): void { + // Do nothing. + } + + /** + * {@inheritdoc} + */ + public function setDebugDataValue(string $key, $value): void { + // Do nothing. + } + + /** + * Return the input as string. + * + * @return string + * The input as string. + */ + public function __toString(): string { + return $this->toString(); + } + +} diff --git a/tests/modules/ai_test/src/OperationType/Echo/EchoInterface.php b/tests/modules/ai_test/src/OperationType/Echo/EchoInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..e86638a336440f3d121198bd4c5150318c1142e0 --- /dev/null +++ b/tests/modules/ai_test/src/OperationType/Echo/EchoInterface.php @@ -0,0 +1,33 @@ +<?php + +namespace Drupal\ai_test\OperationType\Echo; + +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\ai\Attribute\OperationType; +use Drupal\ai\OperationType\OperationTypeInterface; + +/** + * Interface for text translation models. + */ +#[OperationType( + id: 'echo', + label: new TranslatableMarkup('Echo'), +)] +interface EchoInterface extends OperationTypeInterface { + + /** + * Translate the text. + * + * @param \Drupal\ai_test\OperationType\Echo\EchoInput|string $input + * The input to echo. + * @param string $model_id + * The model id to use. + * @param array $options + * Extra tags to set. + * + * @return \Drupal\ai_test\OperationType\Echo\EchoOutput + * The translation output. + */ + public function echo(string|EchoInput $input, string $model_id, array $options = []): EchoOutput; + +} diff --git a/tests/modules/ai_test/src/OperationType/Echo/EchoOutput.php b/tests/modules/ai_test/src/OperationType/Echo/EchoOutput.php new file mode 100644 index 0000000000000000000000000000000000000000..0d8efcb9b099ea3937f848f05e25c480ceaf40af --- /dev/null +++ b/tests/modules/ai_test/src/OperationType/Echo/EchoOutput.php @@ -0,0 +1,90 @@ +<?php + +namespace Drupal\ai_test\OperationType\Echo; + +use Drupal\ai\OperationType\OutputInterface; + +/** + * Data transfer output object text translation output. + */ +class EchoOutput implements OutputInterface { + + /** + * The normalized translated text. + * + * @var string + */ + private string $normalized; + + /** + * The raw output from the AI provider. + * + * @var mixed + */ + private mixed $rawOutput; + + /** + * The metadata from the AI provider. + * + * @var mixed + */ + private mixed $metadata; + + /** + * The constructor. + * + * @param string $normalized + * The metadata. + * @param mixed $rawOutput + * The raw output. + * @param mixed $metadata + * The metadata. + */ + public function __construct(string $normalized, mixed $rawOutput, mixed $metadata) { + $this->normalized = $normalized; + $this->rawOutput = $rawOutput; + $this->metadata = $metadata; + } + + /** + * Returns the translated text. + * + * @return string + * The text. + */ + public function getNormalized(): string { + return $this->normalized; + } + + /** + * Gets the raw output from the AI provider. + * + * @return mixed + * The raw output. + */ + public function getRawOutput(): mixed { + return $this->rawOutput; + } + + /** + * Gets the metadata from the AI provider. + * + * @return mixed + * The metadata. + */ + public function getMetadata(): mixed { + return $this->metadata; + } + + /** + * {@inheritdoc} + */ + public function toArray(): array { + return [ + 'normalized' => $this->normalized, + 'rawOutput' => $this->rawOutput, + 'metadata' => $this->metadata, + ]; + } + +} diff --git a/tests/modules/ai_test/src/Plugin/AiProvider/EchoProvider.php b/tests/modules/ai_test/src/Plugin/AiProvider/EchoProvider.php index ad6603de1bbfbed4c366cf5b41b6a652ae3ec5f4..fa199081c2d8162632f0ba06cb186d08641b0f96 100644 --- a/tests/modules/ai_test/src/Plugin/AiProvider/EchoProvider.php +++ b/tests/modules/ai_test/src/Plugin/AiProvider/EchoProvider.php @@ -38,6 +38,9 @@ use Drupal\ai\OperationType\TextToImage\TextToImageOutput; use Drupal\ai\OperationType\TextToSpeech\TextToSpeechInput; use Drupal\ai\OperationType\TextToSpeech\TextToSpeechInterface; use Drupal\ai\OperationType\TextToSpeech\TextToSpeechOutput; +use Drupal\ai_test\OperationType\Echo\EchoInput; +use Drupal\ai_test\OperationType\Echo\EchoInterface; +use Drupal\ai_test\OperationType\Echo\EchoOutput; use Symfony\Component\Yaml\Yaml; /** @@ -54,7 +57,8 @@ class EchoProvider extends AiProviderClientBase implements SpeechToTextInterface, TextToSpeechInterface, ImageClassificationInterface, - TextToImageInterface { + TextToImageInterface, + EchoInterface { /** * {@inheritdoc} @@ -106,6 +110,7 @@ class EchoProvider extends AiProviderClientBase implements 'text_to_speech', 'moderation', 'image_classification', + 'echo', ]; } @@ -282,4 +287,14 @@ class EchoProvider extends AiProviderClientBase implements return new ImageClassificationOutput($output, $response, []); } + /** + * {@inheritdoc} + */ + public function echo(string|EchoInput $input, string $model_id, array $options = []): EchoOutput { + if (!$input instanceof EchoInput) { + $input = new EchoInput($input); + } + return new EchoOutput((string) $input, ['echo' => (string) $input], []); + } + } diff --git a/tests/src/Kernel/OperationType/Custom/EchoOperationHookTest.php b/tests/src/Kernel/OperationType/Custom/EchoOperationHookTest.php new file mode 100644 index 0000000000000000000000000000000000000000..69622d521b11dbfd2e4d87b5c83d6c224973c91d --- /dev/null +++ b/tests/src/Kernel/OperationType/Custom/EchoOperationHookTest.php @@ -0,0 +1,49 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\ai\Kernel\OperationType\Custom; + +use Drupal\KernelTests\KernelTestBase; + +/** + * This tests the echo calling. + * + * @coversDefaultClass \Drupal\ai_test\OperationType\Echo\EchoInterface + * + * @group ai + */ +class EchoOperationHookTest extends KernelTestBase { + + /** + * Modules to enable. + * + * @var array + */ + protected static $modules = [ + 'ai', + 'ai_test', + 'key', + 'file', + 'system', + ]; + + /** + * Test adding a provider via a hook. + */ + public function testCustomEchoOperation(): void { + /** @var \Drupal\ai\AiProviderPluginManager $manager */ + $manager = \Drupal::service('ai.provider'); + $types = $manager->getOperationTypes(); + self::assertContains('echo', array_keys($types)); + self::assertEquals('Echo altered', $types['echo']['label']); + /** @var \Drupal\ai_test\Plugin\AiProvider\EchoProvider $provider */ + $provider = $manager->createInstance('echoai'); + + $input = $this->randomString(); + $output = $provider->echo($input, 'test'); + self::assertEquals($input, $output->getNormalized()); + self::assertEquals(['echo' => $input], $output->getRawOutput()); + } + +}