diff --git a/.cspell-project-words.txt b/.cspell-project-words.txt index 229ae8729dc8808812e13714260fc6564edc193a..b2fda9fe2dd188293ffdf2f012228975ebee68ef 100644 --- a/.cspell-project-words.txt +++ b/.cspell-project-words.txt @@ -1,4 +1,36 @@ +acmymx +agtxee +aowp automator automators +beihz +bfoheo +byzhmc +Cplain +cxcwjm echoai +eoahw flac +gguvde +iwzr +iztkfs +jqykgu +mkdocs +nagg +originalentity +originalentityref +pgta +qlvkq +refentity +referencable +Retryable +rgzuve +rlgsjy +Spatie +tbbhie +tgic +vgtd +webform +yjwm +zcaglk +zpul diff --git a/README.md b/README.md index 314942fd5801fdfba993f6a145ba41de04990d95..26fd1d4fb4edf88d212f136fbc26f21542089b81 100644 --- a/README.md +++ b/README.md @@ -1 +1,27 @@ # AI - ECA + +Artificial Intelligence integration for Event-Condition-Action module, combining the unified framework +and the power of Drupal to perform various AI-related operations. + +## Dependencies + +- Drupal ^10.3 || ^11 +- [AI module](https://drupal.org/project/ai) +- [ECA module](https://drupal.org/project/eca) + +## Getting started + +1. Enable the module +2. Open an ECA model +3. Use an operation type (`Chat`, `Moderation`, `TextToSpeech` etc.) as action + +## Documentation + +This project uses MkDocs for documentation: you can see the current +documentation at [https://project.pages.drupalcode.org/ai/](https://project.pages.drupalcode.org/ai/). +The documentation source files are located in the `docs/` directory, and to +build your own local version of the documentation please follow these steps: + +1. Install MkDocs: `pip install mkdocs mkdocs-material` +2. Run `mkdocs serve` in the project root +3. Open `http://localhost:8000` in your browser diff --git a/composer.json b/composer.json index 9c66d18aa9ed5a90bc8705ea27828dbadfe605df..9576e17c264f53b256266ddeaacf081ce2648cd0 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,14 @@ }, "require": { "drupal/ai": "1.0.x-dev@dev", + "drupal/ai_agents": "1.0.x-dev@dev", + "drupal/core": "^10.3 || ^11", "drupal/eca": "^2.0", - "drupal/core": "^10.3 || ^11" + "drupal/token": "^1.15", + "illuminate/support": "^10.48 || ^11.34" + }, + "require-dev": { + "drupal/schemata": "^1.0", + "spatie/phpunit-snapshot-assertions": "^4.2 || ^5.1" } } diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000000000000000000000000000000000000..26fd1d4fb4edf88d212f136fbc26f21542089b81 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,27 @@ +# AI - ECA + +Artificial Intelligence integration for Event-Condition-Action module, combining the unified framework +and the power of Drupal to perform various AI-related operations. + +## Dependencies + +- Drupal ^10.3 || ^11 +- [AI module](https://drupal.org/project/ai) +- [ECA module](https://drupal.org/project/eca) + +## Getting started + +1. Enable the module +2. Open an ECA model +3. Use an operation type (`Chat`, `Moderation`, `TextToSpeech` etc.) as action + +## Documentation + +This project uses MkDocs for documentation: you can see the current +documentation at [https://project.pages.drupalcode.org/ai/](https://project.pages.drupalcode.org/ai/). +The documentation source files are located in the `docs/` directory, and to +build your own local version of the documentation please follow these steps: + +1. Install MkDocs: `pip install mkdocs mkdocs-material` +2. Run `mkdocs serve` in the project root +3. Open `http://localhost:8000` in your browser diff --git a/docs/modules/agents/index.md b/docs/modules/agents/index.md new file mode 100644 index 0000000000000000000000000000000000000000..b9c949ccda12e1a30785e7d5799767ac4264c632 --- /dev/null +++ b/docs/modules/agents/index.md @@ -0,0 +1,36 @@ +# AI ECA - Agents + +WARNING: this agent has the capability of creating and editing ECA models, without "realizing" that the result may be +destructive. Meaning that, if you ask it to create a model that removes all nodes when a user logs in, nothing is +holding it back. +USE AT YOUR OWN RISK! + +## Scope + +- Answer questions about existing models +- Answer questions about specific components, like events, conditions or actions +- Create new models +- Edit existing models + +## Usage + +- Go to `/admin/config/workflow/eca` +- Click on `Ask AI` and write down your question + - The same button is available on the edit-screen of a model + +## Roadmap + +This has no specific order or priority, just things that I can think of right now + +- Make the prompt more "deterministic": there's an issue where the LLM edits an existing model and doesn't follow the provided JSON schema. +- Refactor the custom Typed Data model once config validation is in core + - https://www.drupal.org/project/eca/issues/3446331 +- Create a separate sub-agent that can interpret an image and provide a prompt for the main one + - See Webform Agent + +## Resources + +- https://webthesis.biblio.polito.it/31175/1/tesi.pdf +- https://github.com/RobJenks/gpt-codegen +- https://github.com/jtlicardo/bpmn-assistant +- https://ceur-ws.org/Vol-3758/paper-15.pdf diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000000000000000000000000000000000000..cb5d184992c5be6db5662cfac230e5c14efe5a43 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,3 @@ +# Usage + +@todo describe which operation type is exposed as an action diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000000000000000000000000000000000000..0bc96944e1c67cb6937dd3c73a272ea2fd5fdfcf --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,10 @@ +site_name: Documentation for AI - ECA module for Drupal +theme: + name: material +nav: + - Home: index.md + - Usage: usage.md + - Modules: + - AI ECA Agents: modules/agents/index.md +docs_dir: docs +site_dir: site diff --git a/modules/agents/ai_eca_agents.info.yml b/modules/agents/ai_eca_agents.info.yml new file mode 100644 index 0000000000000000000000000000000000000000..d0382da4a02f81f1e0ff565c3e6d51dbe626bf8b --- /dev/null +++ b/modules/agents/ai_eca_agents.info.yml @@ -0,0 +1,11 @@ +name: 'AI ECA - Agents' +type: module +description: 'Enable an LLM to answer questions about models and ECA-components.' +core_version_requirement: ^10.3 || ^11 +package: AI +dependencies: + - ai_eca:ai_eca + - ai_agents:ai_agents + - eca:eca_ui + - schemata:schemata_json_schema + - token:token diff --git a/modules/agents/ai_eca_agents.links.action.yml b/modules/agents/ai_eca_agents.links.action.yml new file mode 100644 index 0000000000000000000000000000000000000000..311fb777b43cb4d586e36146b541449c30ee5380 --- /dev/null +++ b/modules/agents/ai_eca_agents.links.action.yml @@ -0,0 +1,6 @@ +ai_eca_agents.ask_ai: + route_name: ai_eca_agents.ask_ai + title: 'Ask AI' + class: '\Drupal\ai_eca_agents\Plugin\Menu\LocalAction\AiEcaAgentsDialogLocalAction' + appears_on: + - entity.eca.collection diff --git a/modules/agents/ai_eca_agents.module b/modules/agents/ai_eca_agents.module new file mode 100644 index 0000000000000000000000000000000000000000..5071496b1c3184478ff48403a4603ac577c59981 --- /dev/null +++ b/modules/agents/ai_eca_agents.module @@ -0,0 +1,18 @@ +<?php + +/** + * @file + * AI ECA Agents module file. + */ + +use Drupal\ai_eca_agents\Hook\FormHooks; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Hook\Attribute\LegacyHook; + +/** + * Implements hook_FORM_ID_alter. + */ +#[LegacyHook] +function ai_eca_agents_form_bpmn_io_modeller_alter(&$form, FormStateInterface $form_state): void { + Drupal::service(FormHooks::class)->formModellerAlter($form, $form_state); +} diff --git a/modules/agents/ai_eca_agents.routing.yml b/modules/agents/ai_eca_agents.routing.yml new file mode 100644 index 0000000000000000000000000000000000000000..d9e58a4c588cc6ba089a0c448635159fd763bc3c --- /dev/null +++ b/modules/agents/ai_eca_agents.routing.yml @@ -0,0 +1,9 @@ +ai_eca_agents.ask_ai: + path: '/api/ai-eca-agents/ask-ai' + defaults: + _title: 'Ask AI' + _form: '\Drupal\ai_eca_agents\Form\AskAiForm' + options: + _admin_route: true + requirements: + _permission: 'administer eca' diff --git a/modules/agents/ai_eca_agents.services.yml b/modules/agents/ai_eca_agents.services.yml new file mode 100644 index 0000000000000000000000000000000000000000..feaf93f412bbf8f2db2efba16dc6c91a674315ec --- /dev/null +++ b/modules/agents/ai_eca_agents.services.yml @@ -0,0 +1,35 @@ +services: + ai_eca_agents.services.data_provider: + class: Drupal\ai_eca_agents\Services\DataProvider\DataProvider + arguments: + - '@eca.service.modeller' + - '@eca.service.condition' + - '@eca.service.action' + - '@entity_type.manager' + - '@ai_eca_agents.services.model_mapper' + - '@serializer' + - '@token.tree_builder' + + ai_eca_agents.services.eca_repository: + class: Drupal\ai_eca_agents\Services\EcaRepository\EcaRepository + arguments: + - '@entity_type.manager' + - '@ai_eca_agents.services.model_mapper' + - '@typed_data_manager' + + ai_eca_agents.services.model_mapper: + class: Drupal\ai_eca_agents\Services\ModelMapper\ModelMapper + arguments: + - '@typed_data_manager' + - '@serializer' + + # Typed data definitions in general can take many forms. This handles final items. + serializer.normalizer.data_definition.schema_json_ai_eca_agents.json: + class: Drupal\ai_eca_agents\Normalizer\json\DataDefinitionNormalizer + decorates: serializer.normalizer.data_definition.schema_json.json + arguments: + - '@serializer.normalizer.data_definition.schema_json_ai_eca_agents.json.inner' + + Drupal\ai_eca_agents\Hook\FormHooks: + class: Drupal\ai_eca_agents\Hook\FormHooks + autowire: true diff --git a/modules/agents/drush.services.yml b/modules/agents/drush.services.yml new file mode 100644 index 0000000000000000000000000000000000000000..572d0cd9f23982b282478d49edae446800d844f1 --- /dev/null +++ b/modules/agents/drush.services.yml @@ -0,0 +1,7 @@ +services: + ai_eca_agents.debug_data_provider: + class: Drupal\ai_eca_agents\Command\DebugDataProviderCommand + arguments: + - '@ai_eca_agents.services.data_provider' + tags: + - { name: console.command } diff --git a/modules/agents/prompts/eca/answerQuestion.yml b/modules/agents/prompts/eca/answerQuestion.yml new file mode 100644 index 0000000000000000000000000000000000000000..451f0e7d388f9a6093eeb904cc102890ebe46c52 --- /dev/null +++ b/modules/agents/prompts/eca/answerQuestion.yml @@ -0,0 +1,25 @@ +preferred_model: gpt-4o +preferred_llm: openai +is_triage: false +name: Answer question +description: This sub-agent is capable of answering questions about existing models, events, conditions or actions that are available in the website. +prompt: + introduction: | + You are a Drupal developer that can answer questions about a possible model of the Event-Condition-Action module. You are also capable of answering questions about the different events, conditions or actions that are available in the website. + + Based on a previous prompt, you will given information about a model or technical details regarding the events, conditions or actions. The input might also be a combination of those things, try to specify your answer based on those. + + Be helpful, elaborate and answer in the best way you can. Try to re-use as much information about the components and which technical information they expose. This can be the tokens that they expose or the module that implements it. + If you do not have enough information to answer the question, please let the user know. + + You can answer with multiple objects if needed. + possible_actions: + info: The answer to the question or the feedback that you do not have enough information to answer that. + formats: + - action: Action ID from the list + answer: The answer to the question + one_shot_learning_examples: + - action: info + answer: The model 'User login' can redirect a user to the correct page. + - action: info + answer: There are a couple of actions that can be used to do something with an entity, like unpublishing, updating the URL alias etc. diff --git a/modules/agents/prompts/eca/buildModel.yml b/modules/agents/prompts/eca/buildModel.yml new file mode 100644 index 0000000000000000000000000000000000000000..a2fde9d9af7492b454eafb771f561034f3ba099e --- /dev/null +++ b/modules/agents/prompts/eca/buildModel.yml @@ -0,0 +1,46 @@ +preferred_model: gpt-4o +preferred_llm: openai +is_triage: false +name: Build model +description: This sub-agent is capable of creating ECA models. +validation: + - [eca_validation] +retries: 2 +prompt: + introduction: | + You are a business process modelling expert, following the BPMN 2.0 standard. The prompts will contain a textual + description of a business process or updates that you need to execute. + Generate a JSON model for the process, complying to the provided JSON Schema. YOU CAN NOT DEVIATE FROM THAT. + + Analyze and identify key elements: + 1. Start events, there should be at least one; + 2. Tasks and their sequence; + 3. Gateways (xor) and an array of â€branches†containing tasks. There is a condition for the decision point and each + branch has a condition label; + 4. Loops: involve repeating tasks until a specific condition is met. + + Other requirements: + * Nested structure: the schema uses nested structures within gateways to represent branching paths; + * When analyzing the process description, identify opportunities to model tasks as parallel whenever possible for + optimization (if it does not contradict the user intended sequence); + * Use clear names for labels and conditions: e.g., instead of "Event 1" or "Action 2", use "User login" or + "Create node"); + * All elements, except gateways, must have a plugin assigned to them and optionally an array of configuration + parameters. You will given a list of possible plugins and their corresponding configuration structure, you can not + deviate from those; + * When given an existing model, you are allowed to modify any part of it. + + possible_actions: + build: The generated JSON model that should be imported. + fail: You are unable to create or alter the model. + formats: + - action: Action ID from the list + model: The JSON model of the process + message: A small summary of the model and how you came to the provided solution or feedback why you were not able to generate a model. + one_shot_learning_examples: + - action: build + model: | + {"model_id":"create_article","label":"Create Article on Page Publish","events":[{"element_id":"event_1","plugin_id":"content_entity:insert","label":"New Page Published","configuration":{"type":"node page"},"successors":[{"element_id":"action_2"}]}],"actions":[{"element_id":"action_2","plugin_id":"eca_token_set_value","label":"Set Page Title Token","configuration":{"token_name":"page_title","token_value":"[entity:title]","use_yaml":false},"successors":[{"element_id":"action_3"}]},{"element_id":"action_3","plugin_id":"eca_token_load_user_current","label":"Load Author Info","configuration":{"token_name":"author_info"},"successors":[{"element_id":"action_4"}]},{"element_id":"action_4","plugin_id":"eca_new_entity","label":"Create New Article","configuration":{"token_name":"new_article","type":"node article","langcode":"en","label":"New Article: [page_title]","published":true,"owner":"[author_info:uid]"},"successors":[{"element_id":"action_5"}]},{"element_id":"action_5","plugin_id":"eca_set_field_value","label":"Set Article Body","configuration":{"field_name":"body.value","field_value":"New article based on page '[page_title]'. Authored by [author_info:name]. Static text: Example static content.","method":"set:force_clear","strip_tags":false,"trim":true,"save_entity":true},"successors":[{"element_id":"action_6"}]},{"element_id":"action_6","plugin_id":"eca_save_entity","label":"Save Article","successors":[]}]} + message: The model "Create Article on Page Publish" creates an article about a new published page, it contains the label and the author of that new page. + - action: fail + message: I was unable to analyze the description. diff --git a/modules/agents/prompts/eca/determineTask.yml b/modules/agents/prompts/eca/determineTask.yml new file mode 100644 index 0000000000000000000000000000000000000000..d41e8555d51ebca9353148bcb8971066e58234b3 --- /dev/null +++ b/modules/agents/prompts/eca/determineTask.yml @@ -0,0 +1,57 @@ +preferred_model: gpt-4o +preferred_llm: openai +is_triage: true +name: Determine task +Description: This agent is the initial agent that determines if the user is trying to manipulate ECA models, wants information or ask questions about models or plugins. +prompt: + introduction: | + You are a Drupal developer that can create or edit configuration for the Event-Condition-Action module. You are also capable of answering questions about the different events, conditions or actions that are available in the website. + + Based on the following context of a task description and comments, you should figure out if they are trying to create a new model, edit an existing one or just asking a question that requires no action. Any question that was already answered, will not be marked as a question. + + You will be given a list of existing models and any events, condition- or action-plugins that are present in the website. + + If the action is "create", "edit" or "info", include the IDs of the existing components you think are required to execute the action as "component_ids" in your answer. There can be multiple but do not generate new IDs. + If the action is "edit", include the ID of the model as "model_id" in your answer. There can only be one model ID and do not generate a new model ID. + If the action is "info" and you believe that an existing model might be the subject of the answer, provide the model ID as "model_id". There can only be one model ID and do not generate a new model ID. + + If you have suggestions on how the determined task type (eg. "create" or "info") should be executed, you can provide that feedback as "feedback" in your answer. Be precise. + If you can't find the answer to the question, you can ask for more information or say that you can't find the answer. + + Only give back one long answer. + possible_actions: + create: They are trying to create an Event-Condition-Action model. + edit: They are trying to edit an existing Event-Condition-Action model. + info: They want information about an existing model or one or more existing events, conditions or actions, without any further action. + fail: It failed due to missing information or being ambivalent. + formats: + - action: Action ID from the list + model_id: The ID of the existing model when the action is "edit" or "info" + component_ids: A list of IDs of components when the action is "create", "edit" or "info" + feedback: An optional message + one_shot_learning_examples: + - action: create + component_ids: + - 'user:login' + - 'content_entity:insert' + - 'eca_current_user_role' + - 'action_goto_action' + - action: edit + model_id: user_login + component_ids: + - 'user:login' + - 'content_entity:insert' + - 'eca_current_user_role' + - 'action_goto_action' + - action: info + model_id: user_login + - action: info + component_ids: + - 'user:login' + - 'content_entity:insert' + - 'eca_current_user_role' + - 'action_goto_action' + - action: fail + feedback: The model "User login" does not exist + - action: fail + feedback: There is no condition plugin about checking the age of the user. diff --git a/modules/agents/src/Command/DebugDataProviderCommand.php b/modules/agents/src/Command/DebugDataProviderCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..105af68d52611d91d29afe7b0fe6460ba010e514 --- /dev/null +++ b/modules/agents/src/Command/DebugDataProviderCommand.php @@ -0,0 +1,68 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\ai_eca_agents\Command; + +use Drupal\Component\Serialization\Json; +use Drupal\ai_eca_agents\Services\DataProvider\DataProviderInterface; +use Drupal\ai_eca_agents\Services\DataProvider\DataViewModeEnum; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * A console command to debug the data provider. + */ +#[AsCommand( + name: 'ai-agents-eca:debug:data', + description: 'Debug the data provider', +)] +final class DebugDataProviderCommand extends Command { + + /** + * Constructs an DebugCommand object. + */ + public function __construct( + protected readonly DataProviderInterface $dataProvider, + ) { + parent::__construct(); + } + + /** + * {@inheritdoc} + */ + protected function configure(): void { + $this + ->addOption('filter', NULL, InputOption::VALUE_OPTIONAL, 'The filter to apply to the provider. Options are "components" or "models"') + ->addOption('vm', NULL, InputOption::VALUE_OPTIONAL, 'The view mode to use. Options are teaser or full.', DataViewModeEnum::Teaser->value) + ->addUsage('ai-agents-eca:debug:data --vm=full') + ->setHelp('Debug the data provider'); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + $this->dataProvider->setViewMode(DataViewModeEnum::from($input->getOption('vm'))); + $response = [ + 'components' => $this->dataProvider->getComponents(), + 'models' => $this->dataProvider->getModels(), + ]; + if (!empty($input->getOption('filter'))) { + $filter = $input->getOption('filter'); + $response = array_filter($response, function (string $key) use ($filter) { + return $key === $filter; + }, ARRAY_FILTER_USE_KEY); + } + + $response = Json::decode(Json::encode($response)); + + $output->writeln(print_r($response, TRUE)); + + return self::SUCCESS; + } + +} diff --git a/modules/agents/src/EcaElementType.php b/modules/agents/src/EcaElementType.php new file mode 100644 index 0000000000000000000000000000000000000000..9582e7ebec4af74f0da188394618862b3d27550a --- /dev/null +++ b/modules/agents/src/EcaElementType.php @@ -0,0 +1,45 @@ +<?php + +namespace Drupal\ai_eca_agents; + +/** + * Enum of the element types. + */ +enum EcaElementType: string { + + case Event = 'event'; + case Condition = 'condition'; + case Action = 'action'; + case Gateway = 'gateway'; + + /** + * Get the plural form of a type. + * + * @return string + * Returns the plural form of a type. + */ + public function getPlural(): string { + return match ($this) { + self::Event => 'events', + self::Condition => 'conditions', + self::Action => 'actions', + self::Gateway => 'gateways', + }; + } + + /** + * Returns a list of types, keyed by their plural form. + * + * @return \Drupal\ai_eca_agents\EcaElementType[] + * The list of types, keyed by their plural form. + */ + public static function getPluralMap(): array { + return [ + self::Event->getPlural() => self::Event, + self::Condition->getPlural() => self::Condition, + self::Action->getPlural() => self::Action, + self::Gateway->getPlural() => self::Gateway, + ]; + } + +} diff --git a/modules/agents/src/EntityViolationException.php b/modules/agents/src/EntityViolationException.php new file mode 100644 index 0000000000000000000000000000000000000000..d498b2d99ff53237d2789a956a1ec09b5f0af38a --- /dev/null +++ b/modules/agents/src/EntityViolationException.php @@ -0,0 +1,55 @@ +<?php + +namespace Drupal\ai_eca_agents; + +use Drupal\Core\Config\ConfigException; +use Symfony\Component\Validator\ConstraintViolationListInterface; + +/** + * An exception thrown when violations against the schema are captured. + */ +class EntityViolationException extends ConfigException { + + /** + * The constraint violations associated with this exception. + * + * @var \Symfony\Component\Validator\ConstraintViolationListInterface|null + */ + protected ?ConstraintViolationListInterface $violations; + + /** + * EntityViolationException constructor. + * + * @param string $message + * The Exception message to throw. + * @param int $code + * The Exception code. + * @param \Throwable|null $previous + * The previous throwable used for the exception chaining. + * @param \Symfony\Component\Validator\ConstraintViolationListInterface|null $violations + * The constraint violations associated with this exception. + */ + public function __construct(string $message = '', int $code = 0, ?\Throwable $previous = NULL, ?ConstraintViolationListInterface $violations = NULL) { + $this->violations = $violations; + + if (empty($message)) { + $message = 'Validation of the entity failed.'; + } + if (!empty($violations)) { + $message = sprintf('%s Violations: %s.', $message, (string) $violations); + } + + parent::__construct($message, $code, $previous); + } + + /** + * Gets the constraint violations associated with this exception. + * + * @return \Symfony\Component\Validator\ConstraintViolationListInterface|null + * The constraint violations. + */ + public function getViolations(): ?ConstraintViolationListInterface { + return $this->violations; + } + +} diff --git a/modules/agents/src/Form/AskAiForm.php b/modules/agents/src/Form/AskAiForm.php new file mode 100644 index 0000000000000000000000000000000000000000..ce4e8655c762449b1b53d81363488068dfb0eb77 --- /dev/null +++ b/modules/agents/src/Form/AskAiForm.php @@ -0,0 +1,221 @@ +<?php + +namespace Drupal\ai_eca_agents\Form; + +use Drupal\Core\Batch\BatchBuilder; +use Drupal\Core\Form\FormBase; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Url; +use Drupal\ai_agents\PluginInterfaces\AiAgentInterface; +use Drupal\ai_agents\Task\Task; +use Drupal\ai_eca_agents\Plugin\AiAgent\Eca; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Response; + +/** + * A form to propose a question to AI. + */ +class AskAiForm extends FormBase { + + /** + * {@inheritdoc} + */ + public function getFormId(): string { + return 'ai_eca_agents_ask_ai'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state): array { + $form['question'] = [ + '#type' => 'textarea', + '#title' => $this->t('Propose your question'), + '#required' => TRUE, + ]; + + // Determine the destination for when the batch process is finished. + $destination = $this->getRequest()->query->get('destination', Url::fromRoute('entity.eca.collection')->toString()); + $form['destination'] = [ + '#type' => 'value', + '#value' => $destination, + ]; + + // Determine if an existing model is the subject of the prompt. + $modelId = $this->getRequest()->query->get('model-id'); + $form['model_id'] = [ + '#type' => 'value', + '#value' => $modelId, + ]; + + $form['actions'] = [ + '#type' => 'actions', + ]; + $form['actions']['submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Ask'), + '#button_type' => 'primary', + ]; + $form['#theme'] = 'confirm_form'; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state): void { + $batch = new BatchBuilder(); + $batch->setTitle($this->t('Solving question.')); + $batch->setInitMessage($this->t('Asking AI which task to perform...')); + $batch->setErrorMessage($this->t('An error occurred during processing.')); + $batch->setFinishCallback([self::class, 'batchFinished']); + + $batch->addOperation([self::class, 'initProcess'], [$form_state->getValue('destination')]); + $batch->addOperation([self::class, 'determineTask'], [ + $form_state->getValue('question'), + $form_state->getValue('model_id'), + ]); + $batch->addOperation([self::class, 'executeTask']); + + batch_set($batch->toArray()); + } + + /** + * Batch operation for initializing the process. + * + * @param string $destination + * The destination for when the process is finished. + * @param array $context + * The context. + */ + public static function initProcess(string $destination, array &$context): void { + $context['message'] = t('Initializing...'); + + $context['results']['destination'] = $destination; + } + + /** + * Batch operation for determining the task to execute. + * + * @param string $question + * The question of the user. + * @param string|null $modelId + * The ECA model ID. + * @param array $context + * The context. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + */ + public static function determineTask(string $question, ?string $modelId, array &$context): void { + $context['message'] = t('Task determined. Executing...'); + + $context['sandbox']['question'] = $question; + $context['sandbox']['model_id'] = $modelId; + $agent = self::initAgent($context); + + // Let the agent decide how it can answer the question. + $solvability = $agent->determineSolvability(); + + if ($solvability === AiAgentInterface::JOB_NOT_SOLVABLE) { + $context['results']['error'] = t('The AI agent could not solve the task.'); + + return; + } + + // Redirect to the convert-endpoint of the BPMN.io-module. + $bpmnIoIsAvailable = \Drupal::moduleHandler()->moduleExists('bpmn_io'); + if ($solvability === AiAgentInterface::JOB_SOLVABLE && $bpmnIoIsAvailable && !empty($modelId)) { + $context['results']['destination'] = Url::fromRoute('bpmn_io.convert', [ + 'eca' => $modelId, + ])->toString(); + } + + $context['results']['dto'] = $agent->getDto(); + } + + /** + * Batch operation for executing the task. + * + * @param array $context + * The context. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + */ + public static function executeTask(array &$context): void { + $context['message'] = t('Task executed. Gathering feedback...'); + + $agent = self::initAgent($context); + + // Solve the question. + $response = $agent->solve(); + $context['results']['response'] = $response; + } + + /** + * Callback for when the batch is finished. + * + * @param bool $success + * A boolean indicating a successful execution. + * @param array $results + * The collection of results. + * + * @return \Symfony\Component\HttpFoundation\Response + * Returns a http-response. + */ + public static function batchFinished(bool $success, array $results): Response { + if (!empty($results['response'])) { + \Drupal::messenger()->addStatus($results['response']); + } + + return new RedirectResponse($results['destination']); + } + + /** + * Initialize the AI agent. + * + * @param array $context + * The context. + * + * @return \Drupal\ai_eca_agents\Plugin\AiAgent\Eca + * Returns the AI agent. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + */ + protected static function initAgent(array $context): Eca { + // Initiate the agent. + /** @var \Drupal\ai_agents\PluginInterfaces\AiAgentInterface $agent */ + $agent = \Drupal::service('plugin.manager.ai_agents') + ->createInstance('eca'); + assert($agent instanceof Eca); + + $dto = []; + if (!empty($context['sandbox']['model_id'])) { + $dto['model_id'] = $context['sandbox']['model_id']; + } + if (!empty($context['results']['dto']) && is_array($context['results']['dto'])) { + $dto = array_merge($dto, $context['results']['dto']); + $dto['setup_agent'] = TRUE; + } + if (!empty($dto)) { + $agent->setDto($dto); + } + + // Prepare the task. + $question = $context['sandbox']['question'] ?? NULL; + $question = $dto['task_description'] ?? $question; + $task = new Task($question); + $agent->setTask($task); + + // Set the provider. + /** @var \Drupal\ai\AiProviderPluginManager $providerManager */ + $providerManager = \Drupal::service('ai.provider'); + $provider = $providerManager->getDefaultProviderForOperationType('chat'); + $agent->setAiProvider($providerManager->createInstance($provider['provider_id'])); + $agent->setModelName($provider['model_id']); + $agent->setAiConfiguration([]); + + return $agent; + } + +} diff --git a/modules/agents/src/Hook/FormHooks.php b/modules/agents/src/Hook/FormHooks.php new file mode 100644 index 0000000000000000000000000000000000000000..0494334ee9d7c9b41fd048d5ed4207c177b35b06 --- /dev/null +++ b/modules/agents/src/Hook/FormHooks.php @@ -0,0 +1,72 @@ +<?php + +namespace Drupal\ai_eca_agents\Hook; + +use Drupal\Component\Serialization\Json; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Hook\Attribute\Hook; +use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\Url; +use Drupal\eca\Entity\Eca; + +/** + * Provides hook implementations for form alterations. + */ +class FormHooks { + + use StringTranslationTrait; + + /** + * Constructs a FormHooks instance. + * + * @param \Drupal\Core\Routing\RouteMatchInterface $routeMatch + * The route match. + */ + public function __construct( + protected RouteMatchInterface $routeMatch, + ) { + } + + /** + * Alters the ECA Modeller form. + * + * @param array $form + * The form. + * @param \Drupal\Core\Form\FormStateInterface $formState + * The form state. + */ + #[Hook('form_bpmn_io_modeller_alter')] + public function formModellerAlter(array &$form, FormStateInterface $formState): void { + // Take the options from the export-link. + /** @var \Drupal\Core\Url $exportLink */ + $exportLink = $form['actions']['export_archive']['#url']; + $options = $exportLink->getOptions(); + + $eca = $this->routeMatch->getParameter('eca'); + if ($eca instanceof Eca) { + $eca = $eca->id(); + } + $options['query']['model-id'] = $eca; + + $form['actions']['ask_ai'] = [ + '#type' => 'link', + '#title' => $this->t('Ask AI'), + '#url' => Url::fromRoute('ai_eca_agents.ask_ai', [], $options), + '#attributes' => [ + 'class' => ['button', 'ai-eca-ask-ai', 'use-ajax'], + 'data-dialog-type' => 'modal', + 'data-dialog-options' => Json::encode([ + 'width' => 1000, + 'dialogClass' => 'ui-dialog-off-canvas', + ]), + ], + '#attached' => [ + 'library' => [ + 'core/drupal.dialog.ajax', + ], + ], + ]; + } + +} diff --git a/modules/agents/src/MissingEventException.php b/modules/agents/src/MissingEventException.php new file mode 100644 index 0000000000000000000000000000000000000000..401a07170106b1be5f311aebeabb71934c82eec0 --- /dev/null +++ b/modules/agents/src/MissingEventException.php @@ -0,0 +1,12 @@ +<?php + +namespace Drupal\ai_eca_agents; + +use Drupal\Core\Config\ConfigException; + +/** + * An exception thrown when no events are present. + */ +class MissingEventException extends ConfigException { + +} diff --git a/modules/agents/src/Normalizer/json/DataDefinitionNormalizer.php b/modules/agents/src/Normalizer/json/DataDefinitionNormalizer.php new file mode 100644 index 0000000000000000000000000000000000000000..72aef2ff835bee916cc071268c78e26609a90d06 --- /dev/null +++ b/modules/agents/src/Normalizer/json/DataDefinitionNormalizer.php @@ -0,0 +1,67 @@ +<?php + +namespace Drupal\ai_eca_agents\Normalizer\json; + +use Drupal\Core\TypedData\DataDefinitionInterface; +use Drupal\schemata_json_schema\Normalizer\json\JsonNormalizerBase; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Normalizer for DataDefinitionInterface instances. + * + * DataDefinitionInterface is the ultimate parent to all data definitions. This + * service must always be low priority for data definitions, otherwise the + * simpler normalization process it supports will take precedence over all the + * complexities most entity properties contain before reaching this level. + * + * DataDefinitionNormalizer produces scalar value definitions. + * + * Unlike the other Normalizer services in the JSON Schema module, this one is + * used by the hal_schemata normalizer. It is unlikely divergent requirements + * will develop. + * + * All the TypedData normalizers extend from this class. + */ +class DataDefinitionNormalizer extends JsonNormalizerBase { + + /** + * The interface or class that this Normalizer supports. + * + * @var string + */ + protected string $supportedInterfaceOrClass = DataDefinitionInterface::class; + + /** + * The inner normalizer. + * + * @var \Symfony\Component\Serializer\Normalizer\NormalizerInterface + */ + protected NormalizerInterface $inner; + + /** + * Constructs a DataDefinitionNormalizer object. + * + * @param \Symfony\Component\Serializer\Normalizer\NormalizerInterface $normalizer + * The decorated normalizer. + */ + public function __construct(NormalizerInterface $normalizer) { + $this->inner = $normalizer; + } + + /** + * {@inheritdoc} + */ + public function normalize($entity, $format = NULL, array $context = []): array|bool|string|int|float|null|\ArrayObject { + $normalized = $this->inner->normalize($entity, $format, $context); + + if ( + empty($entity->getSetting('allowed_values_function')) + && !empty($entity->getSetting('allowed_values')) + ) { + $normalized['properties'][$context['name']]['enum'] = array_keys($entity->getSetting('allowed_values')); + } + + return $normalized; + } + +} diff --git a/modules/agents/src/Plugin/AiAgent/Eca.php b/modules/agents/src/Plugin/AiAgent/Eca.php new file mode 100644 index 0000000000000000000000000000000000000000..a0fe9b2b8794362adf5d91f6d5a96f42a75579c9 --- /dev/null +++ b/modules/agents/src/Plugin/AiAgent/Eca.php @@ -0,0 +1,381 @@ +<?php + +namespace Drupal\ai_eca_agents\Plugin\AiAgent; + +use Drupal\ai_eca_agents\Schema\Eca as EcaSchema; +use Drupal\ai_eca_agents\TypedData\EcaModelDefinition; +use Drupal\Component\Render\MarkupInterface; +use Drupal\Component\Serialization\Json; +use Drupal\Core\Access\AccessResult; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\ai_agents\Attribute\AiAgent; +use Drupal\ai_agents\PluginBase\AiAgentBase; +use Drupal\ai_agents\PluginInterfaces\AiAgentInterface; +use Drupal\ai_agents\Task\TaskInterface; +use Drupal\ai_eca_agents\Services\DataProvider\DataProviderInterface; +use Drupal\ai_eca_agents\Services\DataProvider\DataViewModeEnum; +use Drupal\ai_eca_agents\Services\EcaRepository\EcaRepositoryInterface; +use Drupal\eca\Entity\Eca as EcaEntity; +use Illuminate\Support\Arr; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * The ECA agent. + */ +#[AiAgent( + id: 'eca', + label: new TranslatableMarkup('ECA Agent'), +)] +class Eca extends AiAgentBase implements ContainerFactoryPluginInterface { + + /** + * The DTO. + * + * @var array + */ + protected array $dto; + + /** + * The ECA entity. + * + * @var \Drupal\eca\Entity\Eca|null + */ + protected ?EcaEntity $model = NULL; + + /** + * The ECA data provider. + * + * @var \Drupal\ai_eca_agents\Services\DataProvider\DataProviderInterface + */ + protected DataProviderInterface $dataProvider; + + /** + * The ECA helper. + * + * @var \Drupal\ai_eca_agents\Services\EcaRepository\EcaRepositoryInterface + */ + protected EcaRepositoryInterface $ecaRepository; + + /** + * The serializer. + * + * @var \Symfony\Component\Serializer\SerializerInterface + */ + protected SerializerInterface $serializer; + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + $instance = parent::create($container, $configuration, $plugin_id, $plugin_definition); + $instance->dataProvider = $container->get('ai_eca_agents.services.data_provider'); + $instance->ecaRepository = $container->get('ai_eca_agents.services.eca_repository'); + $instance->serializer = $container->get('serializer'); + $instance->dto = [ + 'task_description' => '', + 'feedback' => '', + 'questions' => [], + 'data' => [], + 'logs' => [], + ]; + + return $instance; + } + + /** + * {@inheritdoc} + */ + public function getId(): string { + return 'eca'; + } + + /** + * {@inheritdoc} + */ + public function agentsNames(): array { + return ['Event-Condition-Action agent']; + } + + /** + * {@inheritdoc} + */ + public function isAvailable(): bool { + return $this->agentHelper->isModuleEnabled('eca'); + } + + /** + * {@inheritdoc} + */ + public function getRetries(): int { + return 2; + } + + /** + * {@inheritdoc} + */ + public function hasAccess() { + if (!$this->currentUser->hasPermission('administer eca')) { + return AccessResult::forbidden(); + } + + return parent::hasAccess(); + } + + /** + * {@inheritdoc} + */ + public function agentsCapabilities(): array { + return [ + 'eca' => [ + 'name' => 'Event-Condition-Action Agent', + 'description' => 'This is agent is capable of adding, editing or informing about Event-Condition-Action models on a Drupal website. Note that it does not add events, conditions or actions as those require a specific implementation via code.', + 'inputs' => [ + 'free_text' => [ + 'name' => 'Prompt', + 'type' => 'string', + 'description' => 'The prompt to create, edit or ask questions about Event-Condition-Actions models.', + 'default_value' => '', + ], + ], + 'outputs' => [ + 'answers' => [ + 'description' => 'The answers to the questions asked about the Event-Condition-Action model.', + 'type' => 'string', + ], + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + public function determineSolvability(): int { + parent::determineSolvability(); + $type = $this->determineTypeOfTask(); + + return match ($type) { + 'create', 'edit' => AiAgentInterface::JOB_SOLVABLE, + 'info' => AiAgentInterface::JOB_SHOULD_ANSWER_QUESTION, + 'fail' => AiAgentInterface::JOB_NEEDS_ANSWERS, + default => AiAgentInterface::JOB_NOT_SOLVABLE, + }; + } + + /** + * {@inheritdoc} + */ + public function solve(): array|string { + if (isset($this->dto['setup_agent']) && $this->dto['setup_agent'] === TRUE) { + parent::determineSolvability(); + } + + switch (Arr::get($this->dto, 'data.0.action')) { + case 'create': + case 'edit': + $this->buildModel(); + break; + + case 'info': + $log = $this->answerQuestion(); + $this->dto['logs'][] = $log; + break; + } + + return Arr::join($this->dto['logs'], "\n"); + } + + /** + * {@inheritdoc} + */ + public function answerQuestion(): string|TranslatableMarkup { + if (!$this->currentUser->hasPermission('administer eca')) { + return $this->t('You do not have permission to do this.'); + } + + $this->dataProvider->setViewMode(DataViewModeEnum::Full); + + $context = []; + if (!empty($this->model)) { + $context['The information of the model, in JSON format'] = sprintf("```json\n%s\n```", Json::encode($this->dataProvider->getModels([$this->model->id()]))); + } + elseif (!empty($this->data[0]['component_ids'])) { + $context['The details about the components'] = sprintf("```json\n%s\n```", Json::encode($this->dataProvider->getComponents($this->data[0]['component_ids']))); + } + + if (empty($context)) { + return $this->t('Sorry, I could not answer your question without anymore context.'); + } + + // Perform the prompt. + $data = $this->agentHelper->runSubAgent('answerQuestion', $context); + + if (isset($data[0]['answer'])) { + $answer = array_map(function ($dataPoint) { + return $dataPoint['answer']; + }, $data); + + return implode("\n", $answer); + } + + return $this->t('Sorry, I got no answers for you.'); + } + + /** + * {@inheritdoc} + */ + public function getHelp(): MarkupInterface { + return $this->t('This agent can figure out event-condition-action models of a file. Just upload and ask.'); + } + + /** + * {@inheritdoc} + */ + public function askQuestion(): array { + return (array) Arr::get($this->dto, 'questions'); + } + + /** + * {@inheritdoc} + */ + public function approveSolution(): void { + $this->dto = Arr::set($this->dto, 'data.0.action', 'create'); + } + + /** + * {@inheritdoc} + */ + public function setTask(TaskInterface $task): void { + parent::setTask($task); + + $this->dto['task_description'] = $task->getDescription(); + } + + /** + * Get the data transfer object. + * + * @return array + * Returns the data transfer object. + */ + public function getDto(): array { + return $this->dto; + } + + /** + * Set the data transfer object. + * + * @param array $dto + * The data transfer object. + */ + public function setDto(array $dto): void { + $this->dto = $dto; + + if (!empty($this->dto['model_id'])) { + $this->model = $this->ecaRepository->get($this->dto['model_id']); + } + } + + /** + * Determine the type of task. + * + * @return string + * Returns the type of task. + * + * @throws \Exception + */ + protected function determineTypeOfTask(): string { + $context = $this->getFullContextOfTask($this->task); + $userContext = [ + 'A summary of the existing models' => sprintf("```json\n%s\n```", Json::encode($this->dataProvider->getModels())), + 'The list of available events, conditions and actions, in JSON format' => sprintf("```json\n%s\n```", Json::encode($this->dataProvider->getComponents())), + ]; + if (!empty($this->model)) { + $context .= "A model already exists, so creation is not possible."; + $userContext['A summary of the existing models'] = sprintf("```json\n%s\n```", Json::encode($this->dataProvider->getModels([$this->model->id()]))); + } + $userContext['Task description and if available comments description'] = $context; + + // Prepare and run the prompt by fetching all the relevant info. + $data = $this->agentHelper->runSubAgent('determineTask', $userContext); + + // Quit early if the returned response isn't what we expected. + if (empty($data[0]['action'])) { + $this->dto['questions'][] = 'Sorry, we could not understand what you wanted to do, please try again.'; + + return 'fail'; + } + + if (in_array($data[0]['action'], [ + 'create', + 'edit', + 'info', + ])) { + if (!empty($data[0]['model_id'])) { + $this->dto['model_id'] = $data[0]['model_id']; + $this->model = $this->ecaRepository->get($data[0]['model_id']); + } + + if (!empty($data[0]['feedback'])) { + $this->dto['feedback'] = $data[0]['feedback']; + } + + $this->dto['data'] = $data; + + return $data[0]['action']; + } + + throw new \Exception('Invalid action determined for ECA'); + } + + /** + * Create a configuration item for ECA. + */ + protected function buildModel(): void { + $this->dataProvider->setViewMode(DataViewModeEnum::Full); + + // Prepare the prompt. + $context = []; + + // The schema of the ECA-config that the LLM should follow. + $definition = EcaModelDefinition::create(); + $schema = new EcaSchema($definition, $definition->getPropertyDefinitions()); + $schema = $this->serializer->serialize($schema, 'schema_json:json', []); + $context['JSON Schema of the model'] = sprintf("```json\n%s\n```", $schema); + + // The model that should be edited. + if (!empty($this->model)) { + $models = $this->dataProvider->getModels([$this->model->id()]); + $context['The model to edit'] = sprintf("```json\n%s\n```", Json::encode(reset($models))); + } + + // Components or plugins that the LLM should use. + if (Arr::has($this->dto, 'data.0.component_ids')) { + $componentIds = Arr::get($this->dto, 'data.0.component_ids', []); + $context['The details about the components'] = sprintf("```json\n%s\n```", Json::encode($this->dataProvider->getComponents($componentIds))); + } + + // Optional feedback that the previous prompt provided. + if (Arr::has($this->dto, 'data.0.feedback')) { + $context['Guidelines'] = Arr::get($this->dto, 'data.0.feedback'); + } + + // Execute it. + $data = $this->agentHelper->runSubAgent('buildModel', $context); + + $message = Arr::get($data, '0.message', 'Could not create ECA config.'); + if ( + Arr::get($data, '0.action', 'fail') !== 'build' + || !Arr::has($data, '0.model') + ) { + throw new \Exception($message); + } + + $eca = $this->ecaRepository->build(Json::decode(Arr::get($data, '0.model')), TRUE, $this->model?->id()); + + $this->dto['logs'][] = $this->model ? $this->t('Model "@model" was altered.', ['@model' => $this->model->label()]) : $this->t('A new model was created: "@model".', ['@model' => $eca->label()]); + $this->dto['logs'][] = $message; + } + +} diff --git a/modules/agents/src/Plugin/AiAgentValidation/EcaValidation.php b/modules/agents/src/Plugin/AiAgentValidation/EcaValidation.php new file mode 100644 index 0000000000000000000000000000000000000000..3b25949a4dd9011729409bb5a92427309c155138 --- /dev/null +++ b/modules/agents/src/Plugin/AiAgentValidation/EcaValidation.php @@ -0,0 +1,121 @@ +<?php + +namespace Drupal\ai_eca_agents\Plugin\AiAgentValidation; + +use Drupal\ai_eca_agents\Services\ModelMapper\ModelMapperInterface; +use Drupal\Component\Serialization\Json; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\ai\OperationType\Chat\ChatMessage; +use Drupal\ai\Service\PromptJsonDecoder\PromptJsonDecoderInterface; +use Drupal\ai_agents\Attribute\AiAgentValidation; +use Drupal\ai_agents\Exception\AgentRetryableValidationException; +use Drupal\ai_agents\PluginBase\AiAgentValidationPluginBase; +use Drupal\ai_agents\PluginInterfaces\AiAgentValidationInterface; +use Drupal\Core\TypedData\Exception\MissingDataException; +use Illuminate\Support\Arr; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * The validation of the ECA agent. + */ +#[AiAgentValidation( + id: 'eca_validation', + label: new TranslatableMarkup('ECA Validation'), +)] +class EcaValidation extends AiAgentValidationPluginBase implements ContainerFactoryPluginInterface { + + /** + * The JSON-decoder of the prompt. + * + * @var \Drupal\ai\Service\PromptJsonDecoder\PromptJsonDecoderInterface + */ + protected PromptJsonDecoderInterface $promptJsonDecoder; + + /** + * The model mapper. + * + * @var \Drupal\ai_eca_agents\Services\ModelMapper\ModelMapperInterface + */ + protected ModelMapperInterface $modelMapper; + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): AiAgentValidationInterface { + $instance = new static($configuration, $plugin_id, $plugin_definition); + $instance->promptJsonDecoder = $container->get('ai.prompt_json_decode'); + $instance->modelMapper = $container->get('ai_eca_agents.services.model_mapper'); + + return $instance; + } + + /** + * {@inheritdoc} + */ + public function defaultValidation(mixed $data): bool { + $data = $this->decodeData($data); + if (empty($data)) { + throw new AgentRetryableValidationException( + 'The LLM response failed validation: could not decode from JSON.', + 0, + NULL, + 'You MUST only provide a RFC8259 compliant JSON response.' + ); + } + + if (!Arr::has($data, '0.message')) { + throw new AgentRetryableValidationException( + 'The LLM response failed validation: feedback was not provided.', + 0, + NULL, + 'You MUST provide a summary of your action or give feedback why you failed.' + ); + } + + // Stop validation if the LLM failed to return a response. + if (Arr::get($data, '0.action', 'fail')) { + return TRUE; + } + + // Validate the response against ECA-model schema. + try { + $this->modelMapper->fromPayload(Json::decode(Arr::get($data, '0.model'))); + } + catch (MissingDataException $e) { + throw new AgentRetryableValidationException( + 'The LLM response failed validation: the model definition was violated.', + 0, + NULL, + sprintf('Fix the following violations: %s.', $e->getMessage()) + ); + } + + return TRUE; + } + + /** + * Decodes the source data. + * + * @param mixed $source + * The source. + * + * @return array|null + * Returns the decoded data or NULL. + */ + protected function decodeData(mixed $source): ?array { + $text = NULL; + + switch (TRUE) { + case $source instanceof ChatMessage: + return $this->promptJsonDecoder->decode($source); + + case is_string($source): + $text = $source; + break; + } + + return Json::decode($text ?? ''); + } + +} diff --git a/modules/agents/src/Plugin/DataType/EcaGateway.php b/modules/agents/src/Plugin/DataType/EcaGateway.php new file mode 100644 index 0000000000000000000000000000000000000000..8162a97e1e3e944070cfb9edae61e93a858817ea --- /dev/null +++ b/modules/agents/src/Plugin/DataType/EcaGateway.php @@ -0,0 +1,20 @@ +<?php + +namespace Drupal\ai_eca_agents\Plugin\DataType; + +use Drupal\ai_eca_agents\TypedData\EcaGatewayDefinition; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Core\TypedData\Attribute\DataType; +use Drupal\Core\TypedData\Plugin\DataType\Map; + +/** + * Data type plugin of the ECA Gateway data type. + */ +#[DataType( + id: 'eca_gateway', + label: new TranslatableMarkup('ECA Gateway'), + definition_class: EcaGatewayDefinition::class, +)] +class EcaGateway extends Map { + +} diff --git a/modules/agents/src/Plugin/DataType/EcaModel.php b/modules/agents/src/Plugin/DataType/EcaModel.php new file mode 100644 index 0000000000000000000000000000000000000000..fb622d09315d3beac4120ce9458cf6f6c5a81181 --- /dev/null +++ b/modules/agents/src/Plugin/DataType/EcaModel.php @@ -0,0 +1,27 @@ +<?php + +namespace Drupal\ai_eca_agents\Plugin\DataType; + +use Drupal\ai_eca_agents\TypedData\EcaModelDefinition; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Core\TypedData\Attribute\DataType; +use Drupal\Core\TypedData\Plugin\DataType\Map; + +/** + * Data type plugin of the ECA Model data type. + */ +#[DataType( + id: 'eca_model', + label: new TranslatableMarkup('ECA Model'), + definition_class: EcaModelDefinition::class, +)] +class EcaModel extends Map { + + /** + * {@inheritdoc} + */ + public function getName(): string { + return !empty($this->get('label')->getString()) ? $this->get('label')->getString() : 'the model'; + } + +} diff --git a/modules/agents/src/Plugin/DataType/EcaPlugin.php b/modules/agents/src/Plugin/DataType/EcaPlugin.php new file mode 100644 index 0000000000000000000000000000000000000000..1cd4f57f01113ccfcd50d1c67d6e11eae742e517 --- /dev/null +++ b/modules/agents/src/Plugin/DataType/EcaPlugin.php @@ -0,0 +1,33 @@ +<?php + +namespace Drupal\ai_eca_agents\Plugin\DataType; + +use Drupal\ai_eca_agents\TypedData\EcaPluginDefinition; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Core\TypedData\Attribute\DataType; +use Drupal\Core\TypedData\Plugin\DataType\Map; + +/** + * Data type plugin of the ECA Plugin data type. + */ +#[DataType( + id: 'eca_plugin', + label: new TranslatableMarkup('ECA Plugin'), + definition_class: EcaPluginDefinition::class, +)] +class EcaPlugin extends Map { + + /** + * {@inheritdoc} + */ + public function getName(): string { + try { + return $this->get('label')->getString(); + } + catch (\InvalidArgumentException $e) { + // The definition of the plugin doesn't specify a label. + return parent::getName(); + } + } + +} diff --git a/modules/agents/src/Plugin/DataType/EcaSuccessor.php b/modules/agents/src/Plugin/DataType/EcaSuccessor.php new file mode 100644 index 0000000000000000000000000000000000000000..90a65a848ef1db4bc67de869fd89256d60ccae33 --- /dev/null +++ b/modules/agents/src/Plugin/DataType/EcaSuccessor.php @@ -0,0 +1,20 @@ +<?php + +namespace Drupal\ai_eca_agents\Plugin\DataType; + +use Drupal\ai_eca_agents\TypedData\EcaSuccessorDefinition; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Core\TypedData\Attribute\DataType; +use Drupal\Core\TypedData\Plugin\DataType\Map; + +/** + * Data type plugin of the ECA Successor data type. + */ +#[DataType( + id: 'eca_successor', + label: new TranslatableMarkup('ECA Successor'), + definition_class: EcaSuccessorDefinition::class +)] +class EcaSuccessor extends Map { + +} diff --git a/modules/agents/src/Plugin/Menu/LocalAction/AiEcaAgentsDialogLocalAction.php b/modules/agents/src/Plugin/Menu/LocalAction/AiEcaAgentsDialogLocalAction.php new file mode 100644 index 0000000000000000000000000000000000000000..fdcf975a2525f6cc04e1a6e99b200301b92e45d0 --- /dev/null +++ b/modules/agents/src/Plugin/Menu/LocalAction/AiEcaAgentsDialogLocalAction.php @@ -0,0 +1,35 @@ +<?php + +namespace Drupal\ai_eca_agents\Plugin\Menu\LocalAction; + +use Drupal\Component\Serialization\Json; +use Drupal\Component\Utility\NestedArray; +use Drupal\Core\Menu\LocalActionDefault; +use Drupal\Core\Routing\RouteMatchInterface; + +/** + * Defines a local action plugin with the needed dialog attributes. + */ +class AiEcaAgentsDialogLocalAction extends LocalActionDefault { + + /** + * {@inheritdoc} + */ + public function getOptions(RouteMatchInterface $route_match) { + $options = parent::getOptions($route_match); + + $attributes = [ + 'class' => ['use-ajax'], + 'data-dialog-type' => 'modal', + 'data-dialog-options' => Json::encode([ + 'width' => 1000, + 'dialogClass' => 'ui-dialog-off-canvas', + ]), + ]; + $options['attributes'] = $this->pluginDefinition['attributes'] ?? []; + $options['attributes'] = NestedArray::mergeDeep($options['attributes'], $attributes); + + return $options; + } + +} diff --git a/modules/agents/src/Plugin/Validation/Constraint/SuccessorsAreValidConstraint.php b/modules/agents/src/Plugin/Validation/Constraint/SuccessorsAreValidConstraint.php new file mode 100644 index 0000000000000000000000000000000000000000..d064a38a95033915dc12a6c1f3d9d17480a48689 --- /dev/null +++ b/modules/agents/src/Plugin/Validation/Constraint/SuccessorsAreValidConstraint.php @@ -0,0 +1,46 @@ +<?php + +namespace Drupal\ai_eca_agents\Plugin\Validation\Constraint; + +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Core\Validation\Attribute\Constraint; +use Symfony\Component\Validator\Constraint as SymfonyConstraint; + +/** + * Checks if all successors are valid. + */ +#[Constraint( + id: 'SuccessorsAreValid', + label: new TranslatableMarkup('Successor are valid') +)] +class SuccessorsAreValidConstraint extends SymfonyConstraint { + + /** + * The error message if the successor is invalid. + * + * @var string + */ + public string $invalidSuccessorMessage = "Invalid successor ID '@successorId' for @type '@elementId'. Must reference a gateway or action."; + + /** + * The error message if the condition is invalid. + * + * @var string + */ + public string $invalidConditionMessage = "Invalid condition '@conditionId' for successor of @type '@elementId'. Must be in the list of conditions."; + + /** + * The error message if the event is succeeded by another event. + * + * @var string + */ + public string $disallowedSuccessorEvent = "Event '@elementId' cannot be succeeded by another event."; + + /** + * The error message if a non-event element is succeeded by an event. + * + * @var string + */ + public string $disallowedSuccessor = "@type '@elementId' cannot be succeeded by an event."; + +} diff --git a/modules/agents/src/Plugin/Validation/Constraint/SuccessorsAreValidConstraintValidator.php b/modules/agents/src/Plugin/Validation/Constraint/SuccessorsAreValidConstraintValidator.php new file mode 100644 index 0000000000000000000000000000000000000000..41d8968acfbd9d53bc4a797e763e2e64dcbeb4a5 --- /dev/null +++ b/modules/agents/src/Plugin/Validation/Constraint/SuccessorsAreValidConstraintValidator.php @@ -0,0 +1,99 @@ +<?php + +namespace Drupal\ai_eca_agents\Plugin\Validation\Constraint; + +use Drupal\ai_eca_agents\EcaElementType; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; + +/** + * Validator of the SuccessorsAreValid-constraint. + */ +class SuccessorsAreValidConstraintValidator extends ConstraintValidator { + + /** + * {@inheritdoc} + */ + public function validate(mixed $value, Constraint $constraint): void { + assert($constraint instanceof SuccessorsAreValidConstraint); + + $lookup = [ + EcaElementType::Event->getPlural() => array_flip(array_column($value['events'] ?? [], 'element_id')), + EcaElementType::Condition->getPlural() => array_flip(array_column($value['conditions'] ?? [], 'element_id')), + EcaElementType::Action->getPlural() => array_flip(array_column($value['actions'] ?? [], 'element_id')), + EcaElementType::Gateway->getPlural() => array_flip(array_column($value['gateways'] ?? [], 'gateway_id')), + ]; + + foreach (EcaElementType::getPluralMap() as $plural => $type) { + if (empty($value[$plural])) { + continue; + } + + foreach ($value[$plural] as $element) { + $this->validateSuccessor($constraint, $element, $type, $lookup); + } + } + } + + /** + * Validate the successors of an element. + * + * @param \Drupal\ai_eca_agents\Plugin\Validation\Constraint\SuccessorsAreValidConstraint $constraint + * The constraint. + * @param array $element + * The element to validate. + * @param \Drupal\ai_eca_agents\EcaElementType $type + * The type of the element. + * @param array $lookup + * The lookup-array containing all the referencable IDs. + */ + protected function validateSuccessor(SuccessorsAreValidConstraint $constraint, array $element, EcaElementType $type, array $lookup): void { + if (empty($element['successors'])) { + return; + } + + foreach ($element['successors'] as $successor) { + $successorId = $successor['element_id'] ?? $element['gateway_id']; + $conditionId = $successor['condition'] ?? NULL; + + // Check if the successor ID is valid (must be a gateway or an action). + if ( + !isset($lookup[EcaElementType::Action->getPlural()][$successorId]) + && !isset($lookup[EcaElementType::Gateway->getPlural()][$successorId]) + ) { + $this->context->addViolation($constraint->invalidSuccessorMessage, [ + '@successorId' => $successorId, + '@type' => $type->name, + '@elementId' => $element['element_id'], + ]); + } + + // Check if the condition ID is valid. + if ($conditionId && !isset($lookup[EcaElementType::Condition->getPlural()][$conditionId])) { + $this->context->addViolation($constraint->invalidConditionMessage, [ + '@conditionId' => $conditionId, + '@type' => $type->name, + '@elementId' => $element['element_id'], + ]); + } + + // Check for disallowed successor relationships. + if ( + ($type === EcaElementType::Action || $type === EcaElementType::Gateway) + && isset($lookup[EcaElementType::Event->getPlural()][$successorId]) + ) { + $elementId = $element['element_id'] ?? $element['gateway_id']; + $this->context->addViolation($constraint->disallowedSuccessor, [ + '@type' => $type->name, + '@elementId' => $elementId, + ]); + } + if ($type === EcaElementType::Event && isset($lookup[EcaElementType::Event->getPlural()][$successorId])) { + $this->context->addViolation($constraint->disallowedSuccessorEvent, [ + '@elementId' => $element['element_id'], + ]); + } + } + } + +} diff --git a/modules/agents/src/Schema/Eca.php b/modules/agents/src/Schema/Eca.php new file mode 100644 index 0000000000000000000000000000000000000000..7e59476d0e433713e0d12294a75fa44f9c2ba660 --- /dev/null +++ b/modules/agents/src/Schema/Eca.php @@ -0,0 +1,88 @@ +<?php + +namespace Drupal\ai_eca_agents\Schema; + +use Drupal\Core\Cache\RefinableCacheableDependencyTrait; +use Drupal\Core\TypedData\DataDefinitionInterface; +use Drupal\schemata\Schema\SchemaInterface; + +/** + * Provides support for the ECA Model data type in Schemata. + */ +class Eca implements SchemaInterface { + + use RefinableCacheableDependencyTrait; + + /** + * The data definition. + * + * @var \Drupal\Core\TypedData\DataDefinitionInterface + */ + protected DataDefinitionInterface $definition; + + /** + * Metadata values that describe the schema. + * + * @var array + */ + protected array $metadata = [ + 'title' => 'ECA Model Schema', + 'description' => 'The schema describing the properties of an ECA model.', + ]; + + /** + * Typed Data objects for all properties. + * + * @var \Drupal\Core\TypedData\DataDefinitionInterface[] + */ + protected array $properties = []; + + /** + * Constructs an ECA schema. + * + * @param \Drupal\Core\TypedData\DataDefinitionInterface $definition + * The Typed Data definition. + * @param array $properties + * The properties. + */ + public function __construct(DataDefinitionInterface $definition, array $properties = []) { + $this->definition = $definition; + $this->addProperties($properties); + } + + /** + * {@inheritdoc} + */ + public function addProperties(array $properties): void { + $this->properties += $properties; + } + + /** + * {@inheritdoc} + */ + public function getEntityTypeId(): string { + return 'eca_model'; + } + + /** + * {@inheritdoc} + */ + public function getBundleId(): string { + return ''; + } + + /** + * {@inheritdoc} + */ + public function getProperties(): array { + return $this->properties; + } + + /** + * {@inheritdoc} + */ + public function getMetadata(): array { + return $this->metadata; + } + +} diff --git a/modules/agents/src/Services/DataProvider/DataProvider.php b/modules/agents/src/Services/DataProvider/DataProvider.php new file mode 100644 index 0000000000000000000000000000000000000000..76dce05127963810e8447f3f9aa488bf620c0837 --- /dev/null +++ b/modules/agents/src/Services/DataProvider/DataProvider.php @@ -0,0 +1,257 @@ +<?php + +namespace Drupal\ai_eca_agents\Services\DataProvider; + +use Drupal\ai_eca_agents\EcaElementType; +use Drupal\Component\Plugin\ConfigurableInterface; +use Drupal\Component\Plugin\PluginInspectionInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Form\FormState; +use Drupal\Core\Plugin\PluginFormInterface; +use Drupal\ai_eca_agents\Services\ModelMapper\ModelMapperInterface; +use Drupal\eca\Attributes\Token; +use Drupal\eca\Entity\Eca; +use Drupal\eca\Service\Actions; +use Drupal\eca\Service\Conditions; +use Drupal\eca\Service\Modellers; +use Drupal\token\TreeBuilderInterface; +use Illuminate\Support\Arr; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Class for providing ECA-data. + */ +class DataProvider implements DataProviderInterface { + + /** + * The view mode which determines how many details are returned. + * + * @var \Drupal\ai_eca_agents\Services\DataProvider\DataViewModeEnum + */ + protected DataViewModeEnum $viewMode = DataViewModeEnum::Teaser; + + /** + * Constructs an DataProvider instance. + * + * @param \Drupal\eca\Service\Modellers $modellers + * The service for ECA modellers. + * @param \Drupal\eca\Service\Conditions $conditions + * The conditions. + * @param \Drupal\eca\Service\Actions $actions + * The actions. + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager + * The entity type manager. + * @param \Drupal\ai_eca_agents\Services\ModelMapper\ModelMapperInterface $modelMapper + * The model mapper. + * @param \Symfony\Component\Serializer\Normalizer\NormalizerInterface $normalizer + * The normalizer. + * @param \Drupal\token\TreeBuilderInterface $treeBuilder + * The token tree builder. + */ + public function __construct( + protected Modellers $modellers, + protected Conditions $conditions, + protected Actions $actions, + protected EntityTypeManagerInterface $entityTypeManager, + protected ModelMapperInterface $modelMapper, + protected NormalizerInterface $normalizer, + protected TreeBuilderInterface $treeBuilder, + ) { + } + + /** + * {@inheritdoc} + */ + public function getEvents(): array { + $output = []; + + foreach ($this->modellers->events() as $event) { + $info = [ + 'plugin_id' => $event->getPluginId(), + 'name' => $event->getPluginDefinition()['label'], + 'module' => $event->getPluginDefinition()['provider'], + ]; + + if ($this->viewMode === DataViewModeEnum::Full) { + $info['exposed_tokens'] = array_reduce($event->getTokens(), function (array $carry, Token $token) { + $info = [ + 'name' => $token->name, + 'description' => $token->description, + ]; + if (!empty($token->aliases)) { + $info['aliases'] = implode(', ', $token->aliases); + } + $carry[] = $info; + + return $carry; + }, []); + + $info['configuration'] = $this->buildConfig($event); + } + + $output[] = $info; + } + + return $output; + } + + /** + * {@inheritdoc} + */ + public function getConditions(): array { + return $this->convertPlugins($this->conditions->conditions()); + } + + /** + * {@inheritdoc} + */ + public function getActions(): array { + return $this->convertPlugins($this->actions->actions()); + } + + /** + * {@inheritdoc} + */ + public function getComponents(array $filterIds = []): array { + $filterFunction = fn ($plugin) => in_array($plugin['plugin_id'], $filterIds, TRUE); + + return [ + EcaElementType::Event->getPlural() => empty($filterIds) ? $this->getEvents() : array_values(array_filter($this->getEvents(), $filterFunction)), + EcaElementType::Condition->getPlural() => empty($filterIds) ? $this->getConditions() : array_values(array_filter($this->getConditions(), $filterFunction)), + EcaElementType::Action->getPlural() => empty($filterIds) ? $this->getActions() : array_values(array_filter($this->getActions(), $filterFunction)), + ]; + } + + /** + * {@inheritdoc} + */ + public function getModels(array $filterIds = []): array { + /** @var \Drupal\eca\Entity\EcaStorage $storage */ + $storage = $this->entityTypeManager->getStorage('eca'); + $models = $storage->loadMultiple(); + + if (!empty($filterIds)) { + $models = array_filter($models, function (Eca $model) use ($filterIds) { + return in_array($model->id(), $filterIds, TRUE); + }); + } + + return array_reduce($models, function (array $carry, Eca $eca) { + $model = $this->modelMapper->fromEntity($eca); + $data = array_filter($this->normalizer->normalize($model)); + + if ($this->viewMode === DataViewModeEnum::Teaser) { + $data = Arr::only($data, ['model_id', 'label', 'description']); + } + + $carry[] = $data; + + return $carry; + }, []); + } + + /** + * {@inheritdoc} + */ + public function getTokens(): array { + $output = []; + + $render = $this->treeBuilder->buildAllRenderable(); + + foreach ($render['#token_tree'] as $typeInfo) { + foreach ($typeInfo['tokens'] as $token => $tokenInfo) { + $output[$token] = [ + 'name' => $tokenInfo['name'], + ]; + if (!empty($tokenInfo['description'])) { + $output[$token]['description'] = $tokenInfo['description']; + } + } + } + + return $output; + } + + /** + * {@inheritdoc} + */ + public function setViewMode(DataViewModeEnum $viewMode): DataProviderInterface { + $this->viewMode = $viewMode; + + return $this; + } + + /** + * Converts a list of plugins to an array of essential information. + * + * @param \Drupal\Component\Plugin\PluginInspectionInterface[] $plugins + * The list of plugins. + * + * @return array + * Returns a converted list of plugins with essential information. + */ + protected function convertPlugins(array $plugins): array { + $output = []; + + foreach ($plugins as $plugin) { + $info = [ + 'plugin_id' => $plugin->getPluginId(), + 'name' => (string) $plugin->getPluginDefinition()['label'], + ]; + + if ($this->viewMode === DataViewModeEnum::Full) { + $info['configuration'] = $this->buildConfig($plugin); + } + + if (!empty($plugin->getPluginDefinition()['description'])) { + $info['description'] = (string) $plugin->getPluginDefinition()['description']; + } + + $output[] = array_filter($info); + } + + return $output; + } + + /** + * Build the configuration details for the given plugin. + * + * @param \Drupal\Component\Plugin\PluginInspectionInterface $plugin + * The plugin. + * + * @return array + * Returns the configuration details. + */ + protected function buildConfig(PluginInspectionInterface $plugin): array { + if (!$plugin instanceof ConfigurableInterface && !$plugin instanceof PluginFormInterface) { + return []; + } + + $defaultConfig = $plugin->defaultConfiguration(); + $configKeys = array_keys($defaultConfig); + $elements = array_filter($plugin->buildConfigurationForm([], new FormState()), function ($key) use ($configKeys) { + return in_array($key, $configKeys); + }, ARRAY_FILTER_USE_KEY); + + return array_reduce(array_keys($elements), function (array $carry, $key) use ($elements, $defaultConfig) { + $config = [ + 'config_id' => $key, + 'name' => (string) $elements[$key]['#title'], + ]; + if (!empty($elements[$key]['#description'])) { + $config['description'] = (string) $elements[$key]['#description']; + } + if (!empty($elements[$key]['#options'])) { + $config['options'] = array_keys($elements[$key]['#options']); + } + if (isset($defaultConfig[$key])) { + $config['value_type'] = gettype($defaultConfig[$key]); + } + + $carry[] = $config; + + return $carry; + }, []); + } + +} diff --git a/modules/agents/src/Services/DataProvider/DataProviderInterface.php b/modules/agents/src/Services/DataProvider/DataProviderInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..85ce273d8d8eb17d4006204b07feb6d3c93302e5 --- /dev/null +++ b/modules/agents/src/Services/DataProvider/DataProviderInterface.php @@ -0,0 +1,78 @@ +<?php + +namespace Drupal\ai_eca_agents\Services\DataProvider; + +/** + * Interface for ECA data provider. + */ +interface DataProviderInterface { + + /** + * Get all the events. + * + * @return array + * Returns the events. + */ + public function getEvents(): array; + + /** + * Get all the conditions. + * + * @return array + * Returns the conditions. + */ + public function getConditions(): array; + + /** + * Get all the actions. + * + * @return array + * Returns the actions. + */ + public function getActions(): array; + + /** + * A wrapper that returns the events, conditions and actions. + * + * @param array $filterIds + * An optional array of IDs to filter by. + * + * @return array + * The collection of events, conditions and actions. + */ + public function getComponents(array $filterIds = []): array; + + /** + * Get models. + * + * @param array $filterIds + * An optional array of IDs to filter by. + * + * @return array + * Returns the models. + */ + public function getModels(array $filterIds = []): array; + + /** + * Get the available tokens. + * + * @return array + * Return all the available tokens. + */ + public function getTokens(): array; + + /** + * Allows overriding the view mode. + * + * This is used for controlling how much details are exposed for the different + * components. + * + * @param \Drupal\ai_eca_agents\Services\DataProvider\DataViewModeEnum $viewMode + * The view mode. + * + * @return \Drupal\ai_eca_agents\Services\DataProvider\DataProviderInterface + * Returns the altered instance. + */ + public function setViewMode(DataViewModeEnum $viewMode): DataProviderInterface; + +} diff --git a/modules/agents/src/Services/DataProvider/DataViewModeEnum.php b/modules/agents/src/Services/DataProvider/DataViewModeEnum.php new file mode 100644 index 0000000000000000000000000000000000000000..1a63783405dc107a69cdf167e22da83639c85a57 --- /dev/null +++ b/modules/agents/src/Services/DataProvider/DataViewModeEnum.php @@ -0,0 +1,13 @@ +<?php + +namespace Drupal\ai_eca_agents\Services\DataProvider; + +/** + * Enumeration determining the view mode of the data. + */ +enum DataViewModeEnum: string { + + case Teaser = 'teaser'; + case Full = 'full'; + +} diff --git a/modules/agents/src/Services/EcaRepository/EcaRepository.php b/modules/agents/src/Services/EcaRepository/EcaRepository.php new file mode 100644 index 0000000000000000000000000000000000000000..bb39e1453990bcd609e2621db1e25a06248071eb --- /dev/null +++ b/modules/agents/src/Services/EcaRepository/EcaRepository.php @@ -0,0 +1,151 @@ +<?php + +namespace Drupal\ai_eca_agents\Services\EcaRepository; + +use Drupal\ai_eca_agents\EntityViolationException; +use Drupal\ai_eca_agents\MissingEventException; +use Drupal\ai_eca_agents\Services\ModelMapper\ModelMapperInterface; +use Drupal\Component\Utility\Random; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\TypedData\TypedDataManagerInterface; +use Drupal\eca\Entity\Eca; + +/** + * Repository for the ECA entity type. + */ +class EcaRepository implements EcaRepositoryInterface { + + /** + * Constructs an EcaHelper-instance. + * + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager + * The entity type manager. + * @param \Drupal\ai_eca_agents\Services\ModelMapper\ModelMapperInterface $modelMapper + * The model mapper. + * @param \Drupal\Core\TypedData\TypedDataManagerInterface $typedDataManager + * The typed data manager. + */ + public function __construct( + protected EntityTypeManagerInterface $entityTypeManager, + protected ModelMapperInterface $modelMapper, + protected TypedDataManagerInterface $typedDataManager, + ) {} + + /** + * {@inheritdoc} + */ + public function get(string $id): ?Eca { + return $this->entityTypeManager->getStorage('eca') + ->load($id); + } + + /** + * {@inheritdoc} + */ + public function build(array $data, bool $save = TRUE, ?string $id = NULL): Eca { + /** @var \Drupal\eca\Entity\EcaStorage $storage */ + $storage = $this->entityTypeManager->getStorage('eca'); + /** @var \Drupal\eca\Entity\Eca $eca */ + $eca = $storage->create(); + if (!empty($id)) { + $eca = $storage->load($id); + } + + // Convert the given data to the ECA-model. + $model = $this->modelMapper->fromPayload($data); + + // Map the model to the entity. + $random = new Random(); + $idFallback = $model->get('model_id')?->getString() ?? sprintf('process_%s', $random->name(7)); + $eca->set('id', $id ?? $idFallback); + $eca->set('label', $model->get('label')->getString()); + $eca->set('modeller', 'fallback'); + $eca->set('version', $eca->get('version') ?? '0.0.1'); + $eca->setStatus(FALSE); + $eca->resetComponents(); + + // Set events. + /** @var \Drupal\ai_eca_agents\Plugin\DataType\EcaPlugin $plugin */ + foreach ($model->get('events') as $plugin) { + $successors = $plugin->get('successors')->getValue() ?? []; + foreach ($successors as &$successor) { + $successor['id'] = $successor['element_id']; + $successor['condition'] ??= ''; + unset($successor['element_id']); + } + + $eca->addEvent( + $plugin->get('element_id')->getString(), + $plugin->get('plugin_id')->getString(), + $plugin->get('label')->getString(), + $plugin->get('configuration')->getValue(), + $successors + ); + } + + // Set conditions. + /** @var \Drupal\ai_eca_agents\Plugin\DataType\EcaPlugin $plugin */ + foreach ($model->get('conditions') as $plugin) { + $eca->addCondition( + $plugin->get('element_id')->getString(), + $plugin->get('plugin_id')->getString(), + $plugin->get('configuration')->getValue(), + ); + } + + // Set gateways. + /** @var \Drupal\ai_eca_agents\Plugin\DataType\EcaGateway $plugin */ + foreach ($model->get('gateways') as $plugin) { + $successors = $plugin->get('successors')->getValue() ?? []; + foreach ($successors as &$successor) { + $successor['id'] = $successor['element_id']; + $successor['condition'] ??= ''; + unset($successor['element_id']); + } + + $eca->addGateway( + $plugin->get('gateway_id')->getString(), + $plugin->get('type')->getValue(), + $successors + ); + } + + // Set actions. + /** @var \Drupal\ai_eca_agents\Plugin\DataType\EcaGateway $plugin */ + foreach ($model->get('actions') as $plugin) { + $successors = $plugin->get('successors')->getValue() ?? []; + foreach ($successors as &$successor) { + $successor['id'] = $successor['element_id']; + $successor['condition'] ??= ''; + unset($successor['element_id']); + } + + $eca->addAction( + $plugin->get('element_id')->getString(), + $plugin->get('plugin_id')->getString(), + $plugin->get('label')->getString(), + $plugin->get('configuration')->getValue() ?? [], + $successors + ); + } + + // Validate the entity. + $definition = $this->typedDataManager->createDataDefinition(sprintf('entity:%s', $eca->getEntityTypeId())); + $violations = $this->typedDataManager->create($definition, $eca) + ->validate(); + if ($violations->count()) { + throw new EntityViolationException('', 0, NULL, $violations); + } + if (empty($eca->getUsedEvents())) { + throw new MissingEventException('No events registered.'); + } + + // Save the entity. + if ($save) { + $eca->save(); + } + + return $eca; + } + +} diff --git a/modules/agents/src/Services/EcaRepository/EcaRepositoryInterface.php b/modules/agents/src/Services/EcaRepository/EcaRepositoryInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..f1f571e4eb13105abfb2ddc0573c6b3456346de2 --- /dev/null +++ b/modules/agents/src/Services/EcaRepository/EcaRepositoryInterface.php @@ -0,0 +1,38 @@ +<?php + +namespace Drupal\ai_eca_agents\Services\EcaRepository; + +use Drupal\eca\Entity\Eca; + +/** + * Repository for the ECA entity type. + */ +interface EcaRepositoryInterface { + + /** + * Load a model by ID. + * + * @param string $id + * The ID to look for. + * + * @return \Drupal\eca\Entity\Eca|null + * The ECA-entity or NULL. + */ + public function get(string $id): ?Eca; + + /** + * Build the ECA-model. + * + * @param array $data + * The data to use. + * @param bool $save + * Toggle to save the model. + * @param string|null $id + * The ID of an existing model to load. + * + * @return \Drupal\eca\Entity\Eca + * Returns the ECA-model. + */ + public function build(array $data, bool $save = TRUE, ?string $id = NULL): Eca; + +} diff --git a/modules/agents/src/Services/ModelMapper/ModelMapper.php b/modules/agents/src/Services/ModelMapper/ModelMapper.php new file mode 100644 index 0000000000000000000000000000000000000000..28f9ef766ac9925a28bad97b7b123659c1b82e26 --- /dev/null +++ b/modules/agents/src/Services/ModelMapper/ModelMapper.php @@ -0,0 +1,200 @@ +<?php + +namespace Drupal\ai_eca_agents\Services\ModelMapper; + +use Drupal\ai_eca_agents\EcaElementType; +use Drupal\ai_eca_agents\Plugin\DataType\EcaModel; +use Drupal\ai_eca_agents\TypedData\EcaGatewayDefinition; +use Drupal\ai_eca_agents\TypedData\EcaModelDefinition; +use Drupal\ai_eca_agents\TypedData\EcaPluginDefinition; +use Drupal\ai_eca_agents\TypedData\EcaSuccessorDefinition; +use Drupal\Core\TypedData\ComplexDataInterface; +use Drupal\Core\TypedData\Exception\MissingDataException; +use Drupal\Core\TypedData\TypedDataManagerInterface; +use Drupal\eca\Entity\Eca; +use Drupal\eca\Entity\Objects\EcaEvent; +use Illuminate\Support\Arr; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Validator\ConstraintViolationListInterface; + +/** + * The model mapper. + */ +class ModelMapper implements ModelMapperInterface { + + /** + * The model mapper. + * + * @param \Drupal\Core\TypedData\TypedDataManagerInterface $typedDataManager + * The typed data manager. + * @param \Symfony\Component\Serializer\Normalizer\NormalizerInterface $normalizer + * The normalizer. + */ + public function __construct( + protected TypedDataManagerInterface $typedDataManager, + protected NormalizerInterface $normalizer, + ) {} + + /** + * {@inheritdoc} + */ + public function fromPayload(array $payload): EcaModel { + $definition = EcaModelDefinition::create(); + /** @var \Drupal\ai_eca_agents\Plugin\DataType\EcaModel $model */ + $model = $this->typedDataManager->create($definition, $payload); + + /** @var \Symfony\Component\Validator\ConstraintViolationList $violations */ + $violations = $model->validate(); + if (count($violations) > 0) { + throw new MissingDataException($this->formatValidationMessages($model, $violations)); + } + + return $model; + } + + /** + * {@inheritdoc} + */ + public function fromEntity(Eca $entity): EcaModel { + $modelDef = EcaModelDefinition::create(); + /** @var \Drupal\ai_eca_agents\Plugin\DataType\EcaModel $model */ + $model = $this->typedDataManager->create($modelDef); + + // Basic properties. + $model->set('model_id', $entity->id()); + $model->set('label', $entity->label()); + if (!empty($entity->getModel()->getDocumentation())) { + $model->set('description', $entity->getModel()->getDocumentation()); + } + + // Events. + $plugins = array_reduce($entity->getUsedEvents(), function (array $carry, EcaEvent $event) { + $successors = $this->mapSuccessorsToModel($event->getSuccessors()); + + $def = EcaPluginDefinition::create(); + $def->setSetting('data_type', EcaElementType::Event); + /** @var \Drupal\ai_eca_agents\Plugin\DataType\EcaPlugin $model */ + $model = $this->typedDataManager->create($def); + $model->set('element_id', $event->getId()); + $model->set('plugin_id', $event->getPlugin()->getPluginId()); + $model->set('label', $event->getLabel()); + $model->set('configuration', $event->getConfiguration()); + $model->set('successors', $successors); + + $carry[] = $this->normalizer->normalize($model); + + return $carry; + }, []); + $model->set('events', $plugins); + + // Conditions. + $conditions = $entity->getConditions(); + $plugins = array_reduce(array_keys($conditions), function (array $carry, string $conditionId) use ($conditions) { + $def = EcaPluginDefinition::create(); + $def->setSetting('data_type', EcaElementType::Condition); + /** @var \Drupal\ai_eca_agents\Plugin\DataType\EcaPlugin $model */ + $model = $this->typedDataManager->create($def); + $model->set('element_id', $conditionId); + $model->set('plugin_id', $conditions[$conditionId]['plugin']); + $model->set('configuration', $conditions[$conditionId]['configuration']); + + $carry[] = $this->normalizer->normalize($model); + + return $carry; + }, []); + $model->set('conditions', $plugins); + + // Actions. + $actions = $entity->getActions(); + $plugins = array_reduce(array_keys($actions), function (array $carry, string $actionId) use ($actions) { + $successors = $this->mapSuccessorsToModel($actions[$actionId]['successors']); + + $def = EcaPluginDefinition::create(); + $def->setSetting('data_type', EcaElementType::Action); + /** @var \Drupal\ai_eca_agents\Plugin\DataType\EcaPlugin $model */ + $model = $this->typedDataManager->create($def); + $model->set('element_id', $actionId); + $model->set('plugin_id', $actions[$actionId]['plugin']); + $model->set('label', $actions[$actionId]['label']); + $model->set('configuration', $actions[$actionId]['configuration']); + $model->set('successors', $successors); + + $carry[] = $this->normalizer->normalize($model); + + return $carry; + }, []); + $model->set('actions', $plugins); + + // Gateways. + $gateways = $entity->get('gateways'); + $plugins = array_reduce(array_keys($gateways), function (array $carry, string $gatewayId) use ($gateways) { + $successors = $this->mapSuccessorsToModel($gateways[$gatewayId]['successors']); + + /** @var \Drupal\ai_eca_agents\Plugin\DataType\EcaPlugin $model */ + $model = $this->typedDataManager->create(EcaGatewayDefinition::create()); + $model->set('gateway_id', $gatewayId); + $model->set('type', $gateways[$gatewayId]['type']); + $model->set('successors', $successors); + + $carry[] = $this->normalizer->normalize($model); + + return $carry; + }, []); + $model->set('gateways', $plugins); + + return $model; + } + + /** + * Format the violation messages in a human-readable way. + * + * @param \Drupal\Core\TypedData\ComplexDataInterface $data + * The typed data object. + * @param \Symfony\Component\Validator\ConstraintViolationListInterface $violations + * The violation list. + * + * @return string + * Returns the formatted violation messages. + */ + protected function formatValidationMessages(ComplexDataInterface $data, ConstraintViolationListInterface $violations): string { + $lines = [ + sprintf('There were validation errors in %s:', $data->getName()), + ]; + /** @var \Symfony\Component\Validator\ConstraintViolationInterface $violation */ + foreach ($violations as $violation) { + $message = sprintf('- %s', $violation->getMessage()); + if (!empty($violation->getPropertyPath())) { + $message = sprintf('- %s: %s', $violation->getPropertyPath(), $violation->getMessage()); + } + $lines[] = $message; + } + + return implode("\n", $lines); + } + + /** + * Map an array of successor-data to the EcaSuccessor data type. + * + * @param array $successors + * The array of successor-data. + * + * @return array + * Returns a normalized version of the EcaSuccessor data type. + * + * @throws \Drupal\Core\TypedData\Exception\MissingDataException + * @throws \Symfony\Component\Serializer\Exception\ExceptionInterface + */ + protected function mapSuccessorsToModel(array $successors): array { + return array_filter(array_reduce($successors, function ($carry, array $successor) { + /** @var \Drupal\ai_eca_agents\Plugin\DataType\EcaSuccessor $model */ + $model = $this->typedDataManager->create(EcaSuccessorDefinition::create()); + $model->set('element_id', $successor['id']); + $model->set('condition', $successor['condition']); + + $carry[] = Arr::whereNotNull($this->normalizer->normalize($model)); + + return $carry; + }, [])); + } + +} diff --git a/modules/agents/src/Services/ModelMapper/ModelMapperInterface.php b/modules/agents/src/Services/ModelMapper/ModelMapperInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..da0af27bb5f96840c76c64bd0127553b01c3c2e0 --- /dev/null +++ b/modules/agents/src/Services/ModelMapper/ModelMapperInterface.php @@ -0,0 +1,35 @@ +<?php + +namespace Drupal\ai_eca_agents\Services\ModelMapper; + +use Drupal\ai_eca_agents\Plugin\DataType\EcaModel; +use Drupal\eca\Entity\Eca; + +/** + * Interface that describes a model mapper. + */ +interface ModelMapperInterface { + + /** + * Map a payload to the ECA Model typed data. + * + * @param array $payload + * The external payload. + * + * @return \Drupal\ai_eca_agents\Plugin\DataType\EcaModel + * Returns the ECA Model typed data. + */ + public function fromPayload(array $payload): EcaModel; + + /** + * Map an ECA-entity to the ECA Model typed data. + * + * @param \Drupal\eca\Entity\Eca $entity + * The ECA entity. + * + * @return \Drupal\ai_eca_agents\Plugin\DataType\EcaModel + * Returns the ECA Model typed data. + */ + public function fromEntity(Eca $entity): EcaModel; + +} diff --git a/modules/agents/src/TypedData/EcaGatewayDefinition.php b/modules/agents/src/TypedData/EcaGatewayDefinition.php new file mode 100644 index 0000000000000000000000000000000000000000..4ba96292c2c40eed3ee10f2aff6f77661e64a8f9 --- /dev/null +++ b/modules/agents/src/TypedData/EcaGatewayDefinition.php @@ -0,0 +1,51 @@ +<?php + +namespace Drupal\ai_eca_agents\TypedData; + +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Core\TypedData\ComplexDataDefinitionBase; +use Drupal\Core\TypedData\DataDefinition; +use Drupal\Core\TypedData\DataDefinitionInterface; +use Drupal\Core\TypedData\ListDataDefinition; + +/** + * Definition of the ECA Gateway data type. + */ +class EcaGatewayDefinition extends ComplexDataDefinitionBase { + + /** + * {@inheritdoc} + */ + public function getPropertyDefinitions(): array { + $properties = []; + + $properties['gateway_id'] = DataDefinition::create('string') + ->setLabel(new TranslatableMarkup('ID of the element')) + ->setRequired(TRUE) + ->addConstraint('Regex', [ + 'pattern' => '/^[\w]+$/', + 'message' => 'The %value ID is not valid.', + ]); + + $properties['type'] = DataDefinition::create('integer') + ->setLabel(new TranslatableMarkup('Type')) + ->setSetting('allowed_values_function', '') + ->setSetting('allowed_values', [0]) + ->addConstraint('Choice', [ + 'choices' => [0], + ]); + + $properties['successors'] = ListDataDefinition::create('eca_successor') + ->setLabel('Successors'); + + return $properties; + } + + /** + * {@inheritdoc} + */ + public static function create($type = 'eca_gateway'): DataDefinitionInterface { + return new self(['type' => $type]); + } + +} diff --git a/modules/agents/src/TypedData/EcaModelDefinition.php b/modules/agents/src/TypedData/EcaModelDefinition.php new file mode 100644 index 0000000000000000000000000000000000000000..4a40b5b5d66e92e4e4b60cc00a74c61796e925a8 --- /dev/null +++ b/modules/agents/src/TypedData/EcaModelDefinition.php @@ -0,0 +1,90 @@ +<?php + +namespace Drupal\ai_eca_agents\TypedData; + +use Drupal\ai_eca_agents\EcaElementType; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Core\TypedData\ComplexDataDefinitionBase; +use Drupal\Core\TypedData\DataDefinition; +use Drupal\Core\TypedData\DataDefinitionInterface; +use Drupal\Core\TypedData\ListDataDefinition; + +/** + * Definition of the ECA Model data type. + */ +class EcaModelDefinition extends ComplexDataDefinitionBase { + + /** + * {@inheritdoc} + */ + public function __construct(array $values = []) { + parent::__construct($values); + + $this->addConstraint('SuccessorsAreValid'); + } + + /** + * {@inheritdoc} + */ + public function getPropertyDefinitions(): array { + $properties = []; + + $properties['model_id'] = DataDefinition::create('string') + ->setLabel(new TranslatableMarkup('ID')) + ->setRequired(TRUE) + ->addConstraint('Regex', [ + 'pattern' => '/^[\w]+$/', + 'message' => 'The %value ID is not valid.', + ]); + + $properties['label'] = DataDefinition::create('string') + ->setLabel(new TranslatableMarkup('Label')) + ->setRequired(TRUE) + ->addConstraint('Length', ['max' => 255]) + ->addConstraint('NotBlank'); + + $properties['version'] = DataDefinition::create('string') + ->setLabel(new TranslatableMarkup('Version')); + + $properties['description'] = DataDefinition::create('string') + ->setLabel(new TranslatableMarkup('Description')); + + $properties['events'] = ListDataDefinition::create('eca_plugin') + ->setLabel(new TranslatableMarkup('Events')) + ->setRequired(TRUE) + ->setItemDefinition( + EcaPluginDefinition::create() + ->setSetting('data_type', EcaElementType::Event) + ) + ->addConstraint('NotNull'); + + $properties['conditions'] = ListDataDefinition::create('eca_plugin') + ->setLabel(new TranslatableMarkup('Conditions')) + ->setItemDefinition( + EcaPluginDefinition::create() + ->setSetting('data_type', EcaElementType::Condition) + ); + + $properties['gateways'] = ListDataDefinition::create('eca_gateway') + ->setLabel(new TranslatableMarkup('Gateways')); + + $properties['actions'] = ListDataDefinition::create('eca_plugin') + ->setLabel(new TranslatableMarkup('Actions')) + ->setRequired(TRUE) + ->setItemDefinition( + EcaPluginDefinition::create() + ->setSetting('data_type', EcaElementType::Action) + ) + ->addConstraint('NotNull'); + + return $properties; + } + + /** + * {@inheritdoc} + */ + public static function create($type = 'eca_model'): DataDefinitionInterface { + return new self(['type' => $type]); + } + +} diff --git a/modules/agents/src/TypedData/EcaPluginDefinition.php b/modules/agents/src/TypedData/EcaPluginDefinition.php new file mode 100644 index 0000000000000000000000000000000000000000..364e9361705de1676c5dcd5b37b6f522ef332710 --- /dev/null +++ b/modules/agents/src/TypedData/EcaPluginDefinition.php @@ -0,0 +1,126 @@ +<?php + +namespace Drupal\ai_eca_agents\TypedData; + +use Drupal\ai_eca_agents\EcaElementType; +use Drupal\Core\Action\ActionInterface; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Core\TypedData\ComplexDataDefinitionBase; +use Drupal\Core\TypedData\DataDefinition; +use Drupal\Core\TypedData\DataDefinitionInterface; +use Drupal\Core\TypedData\ListDataDefinition; +use Drupal\eca\Plugin\ECA\Condition\ConditionInterface; +use Drupal\eca\Plugin\ECA\Event\EventInterface; + +/** + * Definition of the ECA Plugin data type. + */ +class EcaPluginDefinition extends ComplexDataDefinitionBase { + + protected const PROP_LABEL = 'label'; + + protected const PROP_SUCCESSORS = 'successors'; + + /** + * {@inheritdoc} + */ + public function getPropertyDefinitions(): array { + $properties = []; + + $properties['element_id'] = DataDefinition::create('string') + ->setLabel(new TranslatableMarkup('ID of the element')) + ->setRequired(TRUE) + ->addConstraint('Regex', [ + 'pattern' => '/^[\w]+$/', + 'message' => 'The %value ID is not valid.', + ]); + + $properties['plugin_id'] = DataDefinition::create('string') + ->setLabel(new TranslatableMarkup('Plugin ID')) + ->setRequired(TRUE) + ->addConstraint('PluginExists', [ + 'manager' => $this->getPluginManagerId(), + 'interface' => $this->getPluginInterface(), + ]); + + if (in_array(self::PROP_LABEL, $this->getEnabledProperties(), TRUE)) { + $properties['label'] = DataDefinition::create('string') + ->setLabel(new TranslatableMarkup('Label')) + ->setRequired(TRUE) + ->addConstraint('Regex', [ + 'pattern' => '/([^\PC\x09\x0a\x0d])/u', + 'match' => FALSE, + 'message' => 'Text is not allowed to contain control characters, only visible characters.', + ]); + } + + $properties['configuration'] = DataDefinition::create('any'); + + if (in_array(self::PROP_SUCCESSORS, $this->getEnabledProperties(), TRUE)) { + $properties['successors'] = ListDataDefinition::create('eca_successor') + ->setLabel(new TranslatableMarkup('Successors')); + } + + return $properties; + } + + /** + * {@inheritdoc} + */ + public static function create($type = 'eca_plugin'): DataDefinitionInterface { + return new self(['type' => $type]); + } + + /** + * Get the plugin manager ID. + * + * @return string + * Returns the plugin manager ID. + */ + protected function getPluginManagerId(): string { + return match ($this->getSetting('data_type')) { + EcaElementType::Action => 'plugin.manager.action', + EcaElementType::Event => 'plugin.manager.eca.event', + EcaElementType::Condition => 'plugin.manager.eca.condition', + default => throw new \InvalidArgumentException(t('Could not match data type @type to plugin manager ID.', [ + '@type' => $this->getSetting('data_type'), + ])), + }; + } + + /** + * Get the plugin interface. + * + * @return string + * Returns the plugin interface. + */ + protected function getPluginInterface(): string { + return match ($this->getSetting('data_type')) { + EcaElementType::Action => ActionInterface::class, + EcaElementType::Event => EventInterface::class, + EcaElementType::Condition => ConditionInterface::class, + default => throw new \InvalidArgumentException(t('Could not match data type @type to plugin interface.', [ + '@type' => $this->getSetting('data_type'), + ])), + }; + } + + /** + * Get a list of enabled properties. + * + * @return string[] + * The list of enabled properties. + */ + protected function getEnabledProperties(): array { + $default = [ + self::PROP_LABEL, + self::PROP_SUCCESSORS, + ]; + + return match ($this->getSetting('data_type')) { + EcaElementType::Condition => [], + default => $default, + }; + } + +} diff --git a/modules/agents/src/TypedData/EcaSuccessorDefinition.php b/modules/agents/src/TypedData/EcaSuccessorDefinition.php new file mode 100644 index 0000000000000000000000000000000000000000..b797cf4e3167ed920519a60f29f11284e6c760d8 --- /dev/null +++ b/modules/agents/src/TypedData/EcaSuccessorDefinition.php @@ -0,0 +1,46 @@ +<?php + +namespace Drupal\ai_eca_agents\TypedData; + +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Core\TypedData\ComplexDataDefinitionBase; +use Drupal\Core\TypedData\DataDefinition; +use Drupal\Core\TypedData\DataDefinitionInterface; + +/** + * Definition of the ECA Successor data type. + */ +class EcaSuccessorDefinition extends ComplexDataDefinitionBase { + + /** + * {@inheritdoc} + */ + public function getPropertyDefinitions(): array { + $properties = []; + + $properties['element_id'] = DataDefinition::create('string') + ->setLabel(new TranslatableMarkup('The ID of an existing action or gateway.')) + ->setRequired(TRUE) + ->addConstraint('Regex', [ + 'pattern' => '/^[\w]+$/', + 'message' => 'The %value ID is not valid.', + ]); + + $properties['condition'] = DataDefinition::create('string') + ->setLabel(new TranslatableMarkup('The ID of an existing condition.')) + ->addConstraint('Regex', [ + 'pattern' => '/^[\w]+$/', + 'message' => 'The %value ID is not valid.', + ]); + + return $properties; + } + + /** + * {@inheritdoc} + */ + public static function create($type = 'eca_successor'): DataDefinitionInterface { + return new self(['type' => $type]); + } + +} diff --git a/modules/agents/tests/assets/from_payload_0.json b/modules/agents/tests/assets/from_payload_0.json new file mode 100644 index 0000000000000000000000000000000000000000..3425c797d1d69153c20cc49d55d8ccba138d8e70 --- /dev/null +++ b/modules/agents/tests/assets/from_payload_0.json @@ -0,0 +1,91 @@ +{ + "model_id": "process_1", + "label": "Create Article on Page Publish", + "events": [ + { + "element_id": "event_1", + "plugin_id": "content_entity:insert", + "label": "New Page Published", + "configuration": { + "type": "node page" + }, + "successors": [ + { + "element_id": "action_2" + } + ] + } + ], + "actions": [ + { + "element_id": "action_2", + "plugin_id": "eca_token_set_value", + "label": "Set Page Title Token", + "configuration": { + "token_name": "page_title", + "token_value": "[entity:title]", + "use_yaml": false + }, + "successors": [ + { + "element_id": "action_3" + } + ] + }, + { + "element_id": "action_3", + "plugin_id": "eca_token_load_user_current", + "label": "Load Author Info", + "configuration": { + "token_name": "author_info" + }, + "successors": [ + { + "element_id": "action_4" + } + ] + }, + { + "element_id": "action_4", + "plugin_id": "eca_new_entity", + "label": "Create New Article", + "configuration": { + "token_name": "new_article", + "type": "node article", + "langcode": "en", + "label": "New Article: [page_title]", + "published": true, + "owner": "[author_info:uid]" + }, + "successors": [ + { + "element_id": "action_5" + } + ] + }, + { + "element_id": "action_5", + "plugin_id": "eca_set_field_value", + "label": "Set Article Body", + "configuration": { + "field_name": "body.value", + "field_value": "New article based on page '[page_title]'. Authored by [author_info:name]. Static text: Example static content.", + "method": "set:force_clear", + "strip_tags": false, + "trim": true, + "save_entity": true + }, + "successors": [ + { + "element_id": "action_6" + } + ] + }, + { + "element_id": "action_6", + "plugin_id": "eca_save_entity", + "label": "Save Article", + "successors": [] + } + ] +} diff --git a/modules/agents/tests/assets/from_payload_1.json b/modules/agents/tests/assets/from_payload_1.json new file mode 100644 index 0000000000000000000000000000000000000000..fb33b80ec5288b2dbc39de4fd4d051412ea9c7d1 --- /dev/null +++ b/modules/agents/tests/assets/from_payload_1.json @@ -0,0 +1,89 @@ +{ + "events": [ + { + "element_id": "event_1", + "plugin_id": "content_entity:insert", + "label": "New Page Published", + "configuration": { + "type": "node page" + }, + "successors": [ + { + "element_id": "action_2" + } + ] + } + ], + "actions": [ + { + "element_id": "action_2", + "plugin_id": "eca_token_set_value", + "label": "Set Page Title Token", + "configuration": { + "token_name": "page_title", + "token_value": "[entity:title]", + "use_yaml": false + }, + "successors": [ + { + "element_id": "action_3" + } + ] + }, + { + "element_id": "action_3", + "plugin_id": "eca_token_load_user_current", + "label": "Load Author Info", + "configuration": { + "token_name": "author_info" + }, + "successors": [ + { + "element_id": "action_4" + } + ] + }, + { + "element_id": "action_4", + "plugin_id": "eca_new_entity", + "label": "Create New Article", + "configuration": { + "token_name": "new_article", + "type": "node article", + "langcode": "en", + "label": "New Article: [page_title]", + "published": true, + "owner": "[author_info:uid]" + }, + "successors": [ + { + "element_id": "action_5" + } + ] + }, + { + "element_id": "action_5", + "plugin_id": "eca_set_field_value", + "label": "Set Article Body", + "configuration": { + "field_name": "body.value", + "field_value": "New article based on page '[page_title]'. Authored by [author_info:name]. Static text: Example static content.", + "method": "set:force_clear", + "strip_tags": false, + "trim": true, + "save_entity": true + }, + "successors": [ + { + "element_id": "action_6" + } + ] + }, + { + "element_id": "action_6", + "plugin_id": "eca_save_entity", + "label": "Save Article", + "successors": [] + } + ] +} diff --git a/modules/agents/tests/assets/from_payload_2.json b/modules/agents/tests/assets/from_payload_2.json new file mode 100644 index 0000000000000000000000000000000000000000..98bbba1c070d7e1f1506b05b18877343b661e24c --- /dev/null +++ b/modules/agents/tests/assets/from_payload_2.json @@ -0,0 +1,76 @@ +{ + "model_id": "process_1", + "label": "Create Article on Page Publish", + "actions": [ + { + "element_id": "action_2", + "plugin_id": "eca_token_set_value", + "label": "Set Page Title Token", + "configuration": { + "token_name": "page_title", + "token_value": "[entity:title]", + "use_yaml": false + }, + "successors": [ + { + "element_id": "action_3" + } + ] + }, + { + "element_id": "action_3", + "plugin_id": "eca_token_load_user_current", + "label": "Load Author Info", + "configuration": { + "token_name": "author_info" + }, + "successors": [ + { + "element_id": "action_4" + } + ] + }, + { + "element_id": "action_4", + "plugin_id": "eca_new_entity", + "label": "Create New Article", + "configuration": { + "token_name": "new_article", + "type": "node article", + "langcode": "en", + "label": "New Article: [page_title]", + "published": true, + "owner": "[author_info:uid]" + }, + "successors": [ + { + "element_id": "action_5" + } + ] + }, + { + "element_id": "action_5", + "plugin_id": "eca_set_field_value", + "label": "Set Article Body", + "configuration": { + "field_name": "body.value", + "field_value": "New article based on page '[page_title]'. Authored by [author_info:name]. Static text: Example static content.", + "method": "set:force_clear", + "strip_tags": false, + "trim": true, + "save_entity": true + }, + "successors": [ + { + "element_id": "action_6" + } + ] + }, + { + "element_id": "action_6", + "plugin_id": "eca_save_entity", + "label": "Save Article", + "successors": [] + } + ] +} diff --git a/modules/agents/tests/assets/from_payload_3.json b/modules/agents/tests/assets/from_payload_3.json new file mode 100644 index 0000000000000000000000000000000000000000..c564472f5ebccf227f4ff340732b7ac41ea68056 --- /dev/null +++ b/modules/agents/tests/assets/from_payload_3.json @@ -0,0 +1,92 @@ +{ + "model_id": "create_article_on_new_page", + "label": "Create Article on New Page", + "version": "v1", + "events": [ + { + "element_id": "start_event", + "plugin_id": "content_entity:insert", + "label": "New Page Published", + "configuration": { + "type": "node page" + }, + "successors": [ + { + "element_id": "extract_page_title" + } + ] + } + ], + "conditions": [ + { + "element_id": "check_title_for_ai", + "plugin_id": "eca_entity_field_value", + "configuration": { + "field_name": "title", + "expected_value": "AI", + "operator": "contains", + "type": "value", + "case": false, + "negate": false, + "entity": "entity" + } + } + ], + "gateways": [ + { + "gateway_id": "title_check_gateway", + "type": 0, + "successors": [ + { + "element_id": "create_article_unpublished", + "condition": "check_title_for_ai" + }, + { + "element_id": "create_article_published" + } + ] + } + ], + "actions": [ + { + "element_id": "extract_page_title", + "plugin_id": "eca_get_field_value", + "label": "Extract Page Title", + "configuration": { + "field_name": "title", + "token_name": "page_title" + }, + "successors": [ + { + "element_id": "title_check_gateway" + } + ] + }, + { + "element_id": "create_article_unpublished", + "plugin_id": "eca_new_entity", + "label": "Create Unpublished Article", + "configuration": { + "token_name": "new_article", + "type": "node article", + "langcode": "en", + "label": "Article about: {page_title}", + "published": false, + "owner": "{{session_user.uid}}" + } + }, + { + "element_id": "create_article_published", + "plugin_id": "eca_new_entity", + "label": "Create Published Article", + "configuration": { + "token_name": "new_article", + "type": "node article", + "langcode": "en", + "label": "Article about: {page_title}", + "published": true, + "owner": "{{session_user.uid}}" + } + } + ] +} diff --git a/modules/agents/tests/assets/from_payload_4.json b/modules/agents/tests/assets/from_payload_4.json new file mode 100644 index 0000000000000000000000000000000000000000..5eca026dcee80add0fccd84f277a1db43c73bef1 --- /dev/null +++ b/modules/agents/tests/assets/from_payload_4.json @@ -0,0 +1,105 @@ +{ + "model_id": "create_article_from_page", + "label": "Create Article From Page Publication", + "events": [ + { + "element_id": "event_page_published", + "plugin_id": "content_entity:insert", + "label": "Page Published", + "configuration": { + "type": "node page" + }, + "successors": [ + { + "element_id": "condition_check_title", + "condition": "" + } + ] + } + ], + "conditions": [ + { + "element_id": "condition_check_title", + "plugin_id": "eca_entity_field_value", + "configuration": { + "field_name": "title", + "expected_value": "AI", + "operator": "contains", + "type": "value", + "case": false, + "negate": false, + "entity": "event.entity" + } + } + ], + "gateways": [ + { + "gateway_id": "gateway_title_check", + "type": 0, + "successors": [ + { + "element_id": "action_create_article_offline", + "condition": "condition_check_title" + }, + { + "element_id": "action_create_article", + "condition": "" + } + ] + } + ], + "actions": [ + { + "element_id": "action_create_article", + "plugin_id": "eca_new_entity", + "label": "Create New Article", + "configuration": { + "token_name": "new_article", + "type": "node article", + "langcode": "en", + "label": "Article for {{event.entity.title}} by {{event.entity.owner}}", + "published": true, + "owner": "{{event.entity.owner}}" + }, + "successors": [ + { + "element_id": "action_set_article_field", + "condition": "" + } + ] + }, + { + "element_id": "action_create_article_offline", + "plugin_id": "eca_new_entity", + "label": "Create New Article Offline", + "configuration": { + "token_name": "new_article", + "type": "node article", + "langcode": "en", + "label": "Article for {{event.entity.title}} by {{event.entity.owner}}", + "published": false, + "owner": "{{event.entity.owner}}" + }, + "successors": [ + { + "element_id": "action_set_article_field", + "condition": "" + } + ] + }, + { + "element_id": "action_set_article_field", + "plugin_id": "eca_set_field_value", + "label": "Set Article Body", + "configuration": { + "field_name": "body.value", + "field_value": "This article was auto-generated from the page titled '{{event.entity.title}}', authored by {{event.entity.owner}}. Additional static information about the author can be included here.", + "method": "set:clear", + "strip_tags": false, + "trim": true, + "save_entity": true + }, + "successors": [] + } + ] +} diff --git a/modules/agents/tests/src/Kernel/AiEcaAgentsKernelTestBase.php b/modules/agents/tests/src/Kernel/AiEcaAgentsKernelTestBase.php new file mode 100644 index 0000000000000000000000000000000000000000..94fa524ef98df781eeef6d5c79a8e9e5d2a14a4e --- /dev/null +++ b/modules/agents/tests/src/Kernel/AiEcaAgentsKernelTestBase.php @@ -0,0 +1,124 @@ +<?php + +namespace Drupal\Tests\ai_eca_agents\Kernel; + +use Drupal\Component\Serialization\Json; +use Drupal\KernelTests\KernelTestBase; +use Drupal\node\Entity\NodeType; + +/** + * Base class for Kernel-tests regarding AI ECA Agents. + */ +abstract class AiEcaAgentsKernelTestBase extends KernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'ai_eca', + 'ai_eca_agents', + 'eca', + 'eca_base', + 'eca_content', + 'eca_test_model_cross_ref', + 'eca_user', + 'field', + 'node', + 'text', + 'token', + 'user', + 'serialization', + 'schemata', + 'schemata_json_schema', + 'system', + ]; + + /** + * Generate different sets of data payloads. + * + * @return \Generator + * Returns a collection of data payloads. + */ + public static function payloadProvider(): \Generator { + yield [ + Json::decode(file_get_contents(sprintf('%s/../../assets/from_payload_0.json', __DIR__))), + [ + 'events' => 1, + 'conditions' => 0, + 'actions' => 5, + 'label' => 'Create Article on Page Publish', + ], + ]; + + yield [ + Json::decode(file_get_contents(sprintf('%s/../../assets/from_payload_1.json', __DIR__))), + [], + NULL, + 'model_id: This value should not be null', + ]; + + yield [ + Json::decode(file_get_contents(sprintf('%s/../../assets/from_payload_2.json', __DIR__))), + [], + NULL, + 'events: This value should not be null', + ]; + + yield [ + Json::decode(file_get_contents(sprintf('%s/../../assets/from_payload_3.json', __DIR__))), + [ + 'events' => 1, + 'conditions' => 1, + 'gateways' => 1, + 'actions' => 3, + 'label' => 'Create Article on New Page', + ], + ]; + + yield [ + Json::decode(file_get_contents(sprintf('%s/../../assets/from_payload_3.json', __DIR__))), + [ + 'events' => 1, + 'conditions' => 1, + 'gateways' => 1, + 'actions' => 3, + 'label' => 'Create Article on New Page', + 'version' => 'v1', + ], + 'eca_test_0001', + ]; + + yield [ + Json::decode(file_get_contents(sprintf('%s/../../assets/from_payload_4.json', __DIR__))), + [], + NULL, + "Invalid successor ID 'condition_check_title' for Event 'event_page_published'. Must reference a gateway or action.", + ]; + } + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->installEntitySchema('eca'); + $this->installEntitySchema('node'); + $this->installEntitySchema('user'); + + $this->installConfig(static::$modules); + + // Create the Page content type with a standard body field. + /** @var \Drupal\node\NodeTypeInterface $node_type */ + $node_type = NodeType::create(['type' => 'page', 'name' => 'Page']); + $node_type->save(); + node_add_body_field($node_type); + + // Create the Article content type with a standard body field. + /** @var \Drupal\node\NodeTypeInterface $node_type */ + $node_type = NodeType::create(['type' => 'article', 'name' => 'Article']); + $node_type->save(); + node_add_body_field($node_type); + } + +} diff --git a/modules/agents/tests/src/Kernel/EcaModelSchemaTest.php b/modules/agents/tests/src/Kernel/EcaModelSchemaTest.php new file mode 100644 index 0000000000000000000000000000000000000000..95ca0cdd653ad4093af226ed622d45d07149d65a --- /dev/null +++ b/modules/agents/tests/src/Kernel/EcaModelSchemaTest.php @@ -0,0 +1,66 @@ +<?php + +namespace Drupal\Tests\ai_eca_agents\Kernel; + +use Drupal\ai_eca_agents\Schema\Eca as EcaSchema; +use Drupal\ai_eca_agents\TypedData\EcaModelDefinition; +use Drupal\KernelTests\KernelTestBase; +use Spatie\Snapshots\MatchesSnapshots; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * Kernel test for the ECA Model data type. + * + * @group ai_eca_agents + */ +class EcaModelSchemaTest extends KernelTestBase { + + use MatchesSnapshots; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'ai_eca', + 'ai_eca_agents', + 'eca', + 'serialization', + 'schemata', + 'schemata_json_schema', + 'system', + 'token', + 'user', + ]; + + /** + * The serializer. + * + * @var \Symfony\Component\Serializer\SerializerInterface|null + */ + protected ?SerializerInterface $serializer; + + /** + * Validates the generated schema of the Eca Model data type. + */ + public function testSchema(): void { + $definition = EcaModelDefinition::create(); + $schema = new EcaSchema($definition, $definition->getPropertyDefinitions()); + $schema = $this->serializer->serialize($schema, 'schema_json:json', []); + + $this->assertMatchesJsonSnapshot($schema); + } + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->installEntitySchema('user'); + + $this->installConfig(static::$modules); + + $this->serializer = $this->container->get('serializer'); + } + +} diff --git a/modules/agents/tests/src/Kernel/EcaRepositoryTest.php b/modules/agents/tests/src/Kernel/EcaRepositoryTest.php new file mode 100644 index 0000000000000000000000000000000000000000..a82ca3a3ba7a95a5adc2a2a97d705fa2015b223f --- /dev/null +++ b/modules/agents/tests/src/Kernel/EcaRepositoryTest.php @@ -0,0 +1,55 @@ +<?php + +namespace Drupal\Tests\ai_eca_agents\Kernel; + +use Drupal\ai_eca_agents\Services\EcaRepository\EcaRepositoryInterface; + +/** + * Tests various input data for generating ECA models. + * + * @group ai_eca_agents + */ +class EcaRepositoryTest extends AiEcaAgentsKernelTestBase { + + /** + * The ECA repository. + * + * @var \Drupal\ai_eca_agents\Services\EcaRepository\EcaRepositoryInterface|null + */ + protected ?EcaRepositoryInterface $ecaRepository; + + /** + * Build an ECA-model with the provided data. + * + * @dataProvider payloadProvider + */ + public function testBuildModel(array $data, array $assertions, ?string $id = NULL, ?string $errorMessage = NULL): void { + if (!empty($errorMessage)) { + $this->expectExceptionMessage($errorMessage); + } + + $eca = $this->ecaRepository->build($data, TRUE, $id); + + foreach ($assertions as $property => $expected) { + switch (TRUE) { + case is_int($expected): + $this->assertCount($expected, $eca->get($property)); + break; + + case is_string($expected): + $this->assertEquals($expected, $eca->get($property)); + break; + } + } + } + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->ecaRepository = \Drupal::service('ai_eca_agents.services.eca_repository'); + } + +} diff --git a/modules/agents/tests/src/Kernel/ModelMapperTest.php b/modules/agents/tests/src/Kernel/ModelMapperTest.php new file mode 100644 index 0000000000000000000000000000000000000000..3f13f697cd1dc2147524c3929ed3c0ea4e8eba54 --- /dev/null +++ b/modules/agents/tests/src/Kernel/ModelMapperTest.php @@ -0,0 +1,136 @@ +<?php + +namespace Drupal\Tests\ai_eca_agents\Kernel; + +use Drupal\ai_eca_agents\Services\ModelMapper\ModelMapperInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Spatie\Snapshots\MatchesSnapshots; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Tests various input data for generation ECA Model typed data. + * + * @group ai_eca_agents + */ +class ModelMapperTest extends AiEcaAgentsKernelTestBase { + + use MatchesSnapshots; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'ai_eca', + 'ai_eca_agents', + 'eca', + 'eca_base', + 'eca_content', + 'eca_user', + 'eca_test_model_cross_ref', + 'eca_test_model_entity_basics', + 'eca_test_model_set_field_value', + 'field', + 'node', + 'text', + 'token', + 'user', + 'serialization', + 'schemata', + 'schemata_json_schema', + 'system', + 'taxonomy', + ]; + + /** + * The model mapper. + * + * @var \Drupal\ai_eca_agents\Services\ModelMapper\ModelMapperInterface|null + */ + protected ?ModelMapperInterface $modelMapper; + + /** + * The entity type manager. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface|null + */ + protected ?EntityTypeManagerInterface $entityTypeManager; + + /** + * The normalizer. + * + * @var \Symfony\Component\Serializer\Normalizer\NormalizerInterface|null + */ + protected ?NormalizerInterface $normalizer; + + /** + * Build an ECA Model type data object with the provided payloads. + * + * @dataProvider payloadProvider + */ + public function testMappingFromPayload(array $payload, array $assertions, ?string $id = NULL, ?string $errorMessage = NULL): void { + if (!empty($errorMessage)) { + $this->expectExceptionMessage($errorMessage); + } + + $model = $this->modelMapper->fromPayload($payload); + + foreach ($assertions as $property => $expected) { + switch (TRUE) { + case is_int($expected): + $this->assertCount($expected, $model->get($property)->getValue()); + break; + + case is_string($expected): + $this->assertEquals($expected, $model->get($property)->getString()); + break; + } + } + } + + /** + * Build an ECA Model data type object with the provided entities. + * + * @dataProvider entityProvider + */ + public function testMappingFromEntity(string $entityId): void { + /** @var \Drupal\eca\Entity\Eca $entity */ + $entity = $this->entityTypeManager->getStorage('eca') + ->load($entityId); + $model = $this->modelMapper->fromEntity($entity); + $data = $this->normalizer->normalize($model); + + $this->assertMatchesJsonSnapshot(json_encode($data, JSON_PRETTY_PRINT)); + } + + /** + * Generate different sets of ECA entity IDs. + * + * @return \Generator + * Returns a collection of ECA entity IDs. + */ + public static function entityProvider(): \Generator { + yield [ + 'eca_test_0001', + ]; + + yield [ + 'eca_test_0002', + ]; + + yield [ + 'eca_test_0009', + ]; + } + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->modelMapper = \Drupal::service('ai_eca_agents.services.model_mapper'); + $this->entityTypeManager = \Drupal::entityTypeManager(); + $this->normalizer = \Drupal::service('serializer'); + } + +} diff --git a/modules/agents/tests/src/Kernel/__snapshots__/EcaModelSchemaTest__testSchema__1.json b/modules/agents/tests/src/Kernel/__snapshots__/EcaModelSchemaTest__testSchema__1.json new file mode 100644 index 0000000000000000000000000000000000000000..f7eab86e29210876b61c62c079c2a949b1695163 --- /dev/null +++ b/modules/agents/tests/src/Kernel/__snapshots__/EcaModelSchemaTest__testSchema__1.json @@ -0,0 +1,198 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "http://localhost/schemata/eca_model?_format=schema_json&_describes=json", + "type": "object", + "title": "ECA Model Schema", + "description": "The schema describing the properties of an ECA model.", + "properties": { + "model_id": { + "type": "string", + "title": "ID" + }, + "label": { + "type": "string", + "title": "Label" + }, + "version": { + "type": "string", + "title": "Version" + }, + "description": { + "type": "string", + "title": "Description" + }, + "events": { + "type": "array", + "title": "Events", + "items": { + "type": "object", + "properties": { + "element_id": { + "type": "string", + "title": "ID of the element" + }, + "plugin_id": { + "type": "string", + "title": "Plugin ID" + }, + "label": { + "type": "string", + "title": "Label" + }, + "configuration": { + "type": "any" + }, + "successors": { + "type": "array", + "title": "Successors", + "items": { + "type": "object", + "properties": { + "element_id": { + "type": "string", + "title": "The ID of an existing action or gateway." + }, + "condition": { + "type": "string", + "title": "The ID of an existing condition." + } + }, + "required": [ + "element_id" + ] + } + } + }, + "required": [ + "element_id", + "plugin_id", + "label" + ] + }, + "minItems": 1 + }, + "conditions": { + "type": "array", + "title": "Conditions", + "items": { + "type": "object", + "properties": { + "element_id": { + "type": "string", + "title": "ID of the element" + }, + "plugin_id": { + "type": "string", + "title": "Plugin ID" + }, + "configuration": { + "type": "any" + } + }, + "required": [ + "element_id", + "plugin_id" + ] + } + }, + "gateways": { + "type": "array", + "title": "Gateways", + "items": { + "type": "object", + "properties": { + "gateway_id": { + "type": "string", + "title": "ID of the element" + }, + "type": { + "type": "integer", + "title": "Type", + "enum": [ + 0 + ] + }, + "successors": { + "type": "array", + "title": "Successors", + "items": { + "type": "object", + "properties": { + "element_id": { + "type": "string", + "title": "The ID of an existing action or gateway." + }, + "condition": { + "type": "string", + "title": "The ID of an existing condition." + } + }, + "required": [ + "element_id" + ] + } + } + }, + "required": [ + "gateway_id" + ] + } + }, + "actions": { + "type": "array", + "title": "Actions", + "items": { + "type": "object", + "properties": { + "element_id": { + "type": "string", + "title": "ID of the element" + }, + "plugin_id": { + "type": "string", + "title": "Plugin ID" + }, + "label": { + "type": "string", + "title": "Label" + }, + "configuration": { + "type": "any" + }, + "successors": { + "type": "array", + "title": "Successors", + "items": { + "type": "object", + "properties": { + "element_id": { + "type": "string", + "title": "The ID of an existing action or gateway." + }, + "condition": { + "type": "string", + "title": "The ID of an existing condition." + } + }, + "required": [ + "element_id" + ] + } + } + }, + "required": [ + "element_id", + "plugin_id", + "label" + ] + }, + "minItems": 1 + } + }, + "required": [ + "model_id", + "label", + "events", + "actions" + ] +} diff --git a/modules/agents/tests/src/Kernel/__snapshots__/ModelMapperTest__testMappingFromEntity with data set 0__1.json b/modules/agents/tests/src/Kernel/__snapshots__/ModelMapperTest__testMappingFromEntity with data set 0__1.json new file mode 100644 index 0000000000000000000000000000000000000000..4280d51fa7ef3351a4d8cacbd0ac61b70b1eeb10 --- /dev/null +++ b/modules/agents/tests/src/Kernel/__snapshots__/ModelMapperTest__testMappingFromEntity with data set 0__1.json @@ -0,0 +1,285 @@ +{ + "model_id": "eca_test_0001", + "label": "Cross references", + "version": null, + "description": "Two different node types are referring each other. If one node gets saved with a reference to another node, the other node gets automatically updated to link back to the first node.", + "events": [ + { + "element_id": "Event_011cx7s", + "plugin_id": "content_entity:insert", + "label": "Insert node", + "configuration": { + "type": "node _all" + }, + "successors": [ + { + "element_id": "Activity_1rlgsjy", + "condition": "" + } + ] + }, + { + "element_id": "Event_1cfd8ek", + "plugin_id": "content_entity:update", + "label": "Update node", + "configuration": { + "type": "node _all" + }, + "successors": [ + { + "element_id": "Activity_1rlgsjy", + "condition": "" + }, + { + "element_id": "Activity_1cxcwjm", + "condition": "" + } + ] + } + ], + "conditions": [ + { + "element_id": "Flow_0iztkfs", + "plugin_id": "eca_entity_type_bundle", + "configuration": { + "negate": false, + "type": "node type_1", + "entity": "" + } + }, + { + "element_id": "Flow_1jqykgu", + "plugin_id": "eca_entity_type_bundle", + "configuration": { + "negate": false, + "type": "node type_2", + "entity": "" + } + }, + { + "element_id": "Flow_0i81v8o", + "plugin_id": "eca_entity_field_value", + "configuration": { + "case": false, + "expected_value": "[entity:nid]", + "field_name": "field_other_node", + "operator": "equal", + "type": "value", + "negate": true, + "entity": "refentity" + } + }, + { + "element_id": "Flow_1tgic5x", + "plugin_id": "eca_entity_field_value_empty", + "configuration": { + "field_name": "field_other_node", + "negate": true, + "entity": "" + } + }, + { + "element_id": "Flow_0rgzuve", + "plugin_id": "eca_entity_field_value_empty", + "configuration": { + "negate": false, + "field_name": "field_other_node", + "entity": "" + } + }, + { + "element_id": "Flow_0c3s897", + "plugin_id": "eca_entity_field_value_empty", + "configuration": { + "field_name": "field_other_node", + "negate": true, + "entity": "originalentity" + } + } + ], + "gateways": [ + { + "gateway_id": "Gateway_1xl2rvc", + "type": 0, + "successors": [ + { + "element_id": "Activity_0k6im8f", + "condition": "" + }, + { + "element_id": "Activity_1oj601y", + "condition": "" + } + ] + } + ], + "actions": [ + { + "element_id": "Activity_1rlgsjy", + "plugin_id": "eca_token_load_entity", + "label": "Load original entity", + "configuration": { + "token_name": "originalentity", + "from": "current", + "entity_type": "_none", + "entity_id": "", + "revision_id": "", + "properties": "", + "langcode": "_interface", + "latest_revision": false, + "unchanged": true, + "object": "" + }, + "successors": [ + { + "element_id": "Activity_0r1gs9s", + "condition": "Flow_0iztkfs" + }, + { + "element_id": "Activity_0r1gs9s", + "condition": "Flow_1jqykgu" + } + ] + }, + { + "element_id": "Activity_0h8b7vh", + "plugin_id": "eca_token_load_entity_ref", + "label": "Load referenced node", + "configuration": { + "field_name_entity_ref": "field_other_node", + "token_name": "refentity", + "from": "current", + "entity_type": "_none", + "entity_id": "", + "revision_id": "", + "properties": "", + "langcode": "_interface", + "latest_revision": false, + "unchanged": false, + "object": "" + }, + "successors": [ + { + "element_id": "Gateway_1xl2rvc", + "condition": "Flow_0i81v8o" + } + ] + }, + { + "element_id": "Activity_0k6im8f", + "plugin_id": "eca_set_field_value", + "label": "Set Cross Ref", + "configuration": { + "field_name": "field_other_node", + "field_value": "[entity:nid]", + "method": "set:clear", + "strip_tags": false, + "trim": false, + "save_entity": true, + "object": "refentity" + }, + "successors": [] + }, + { + "element_id": "Activity_0r1gs9s", + "plugin_id": "eca_void_and_condition", + "label": "void", + "configuration": [], + "successors": [ + { + "element_id": "Activity_0h8b7vh", + "condition": "Flow_1tgic5x" + }, + { + "element_id": "Activity_1ch3wrr", + "condition": "Flow_0rgzuve" + } + ] + }, + { + "element_id": "Activity_1oj601y", + "plugin_id": "action_message_action", + "label": "Msg", + "configuration": { + "message": "Node [entity:title] references [refentity:title]", + "replace_tokens": true + }, + "successors": [] + }, + { + "element_id": "Activity_1cxcwjm", + "plugin_id": "action_message_action", + "label": "Msg", + "configuration": { + "message": "Node [entity:title] got updated", + "replace_tokens": true + }, + "successors": [] + }, + { + "element_id": "Activity_1w7m4sk", + "plugin_id": "eca_token_load_entity_ref", + "label": "Load referenced node", + "configuration": { + "field_name_entity_ref": "field_other_node", + "token_name": "originalentityref", + "from": "current", + "entity_type": "_none", + "entity_id": "", + "revision_id": "", + "properties": "", + "langcode": "_interface", + "latest_revision": false, + "unchanged": false, + "object": "originalentity" + }, + "successors": [ + { + "element_id": "Activity_1bfoheo", + "condition": "" + }, + { + "element_id": "Activity_077d2t8", + "condition": "" + } + ] + }, + { + "element_id": "Activity_1ch3wrr", + "plugin_id": "eca_void_and_condition", + "label": "void", + "configuration": [], + "successors": [ + { + "element_id": "Activity_1w7m4sk", + "condition": "Flow_0c3s897" + } + ] + }, + { + "element_id": "Activity_1bfoheo", + "plugin_id": "eca_set_field_value", + "label": "Empty Cross Ref", + "configuration": { + "field_name": "field_other_node", + "field_value": "", + "method": "set:clear", + "strip_tags": false, + "trim": false, + "save_entity": true, + "object": "originalentityref" + }, + "successors": [] + }, + { + "element_id": "Activity_077d2t8", + "plugin_id": "action_message_action", + "label": "Msg", + "configuration": { + "message": "The title of the referenced node is [originalentity:field_other_node:entity:title].", + "replace_tokens": true + }, + "successors": [] + } + ] +} diff --git a/modules/agents/tests/src/Kernel/__snapshots__/ModelMapperTest__testMappingFromEntity with data set 1__1.json b/modules/agents/tests/src/Kernel/__snapshots__/ModelMapperTest__testMappingFromEntity with data set 1__1.json new file mode 100644 index 0000000000000000000000000000000000000000..8ba24aa99180d1dad3c5e155b8d03d6bc4030ec9 --- /dev/null +++ b/modules/agents/tests/src/Kernel/__snapshots__/ModelMapperTest__testMappingFromEntity with data set 1__1.json @@ -0,0 +1,180 @@ +{ + "model_id": "eca_test_0002", + "label": "Entity Events Part 1", + "version": null, + "description": "Triggers custom events in the same model and in another model, see also \"Entity Events Part 2\"", + "events": [ + { + "element_id": "Event_0wm7ta0", + "plugin_id": "content_entity:presave", + "label": "Pre-save", + "configuration": { + "type": "node _all" + }, + "successors": [ + { + "element_id": "Activity_1do22d1", + "condition": "" + } + ] + }, + { + "element_id": "Event_0sr0xl6", + "plugin_id": "content_entity:custom", + "label": "C1", + "configuration": { + "event_id": "C1" + }, + "successors": [ + { + "element_id": "Activity_1sh3bdl", + "condition": "" + } + ] + }, + { + "element_id": "Event_1l6ov1l", + "plugin_id": "user:set_user", + "label": "Set current user", + "configuration": [], + "successors": [ + { + "element_id": "Activity_1p5hvp4", + "condition": "" + } + ] + }, + { + "element_id": "Event_0n1zpul", + "plugin_id": "eca_base:eca_custom", + "label": "Cplain", + "configuration": { + "event_id": "" + }, + "successors": [ + { + "element_id": "Activity_1gguvde", + "condition": "" + } + ] + } + ], + "conditions": [], + "gateways": [], + "actions": [ + { + "element_id": "Activity_1do22d1", + "plugin_id": "action_message_action", + "label": "Msg", + "configuration": { + "replace_tokens": false, + "message": "Message 0: [entity:title]" + }, + "successors": [ + { + "element_id": "Activity_03j3ob6", + "condition": "" + }, + { + "element_id": "Activity_1k70gka", + "condition": "" + }, + { + "element_id": "Activity_150pgta", + "condition": "" + }, + { + "element_id": "Activity_00ca469", + "condition": "" + } + ] + }, + { + "element_id": "Activity_03j3ob6", + "plugin_id": "eca_trigger_content_entity_custom_event", + "label": "Trigger C1", + "configuration": { + "event_id": "C1", + "tokens": "", + "object": "" + }, + "successors": [] + }, + { + "element_id": "Activity_1k70gka", + "plugin_id": "eca_trigger_content_entity_custom_event", + "label": "Trigger C2", + "configuration": { + "event_id": "C2", + "tokens": "", + "object": "" + }, + "successors": [] + }, + { + "element_id": "Activity_150pgta", + "plugin_id": "eca_token_load_user_current", + "label": "Load current user", + "configuration": { + "token_name": "user" + }, + "successors": [ + { + "element_id": "Activity_1acmymx", + "condition": "" + } + ] + }, + { + "element_id": "Activity_1acmymx", + "plugin_id": "eca_trigger_content_entity_custom_event", + "label": "Trigger C3", + "configuration": { + "event_id": "C3", + "tokens": "", + "object": "user" + }, + "successors": [] + }, + { + "element_id": "Activity_1sh3bdl", + "plugin_id": "action_message_action", + "label": "Msg", + "configuration": { + "replace_tokens": false, + "message": "Message 1: [entity:title]" + }, + "successors": [] + }, + { + "element_id": "Activity_1p5hvp4", + "plugin_id": "action_message_action", + "label": "Msg", + "configuration": { + "replace_tokens": false, + "message": "Message set current user: [entity:title]" + }, + "successors": [] + }, + { + "element_id": "Activity_1gguvde", + "plugin_id": "action_message_action", + "label": "Msg", + "configuration": { + "replace_tokens": false, + "message": "Message without event: [entity:title]" + }, + "successors": [] + }, + { + "element_id": "Activity_00ca469", + "plugin_id": "eca_trigger_custom_event", + "label": "Trigger Cplain", + "configuration": { + "event_id": "Cplain", + "tokens": "" + }, + "successors": [] + } + ] +} diff --git a/modules/agents/tests/src/Kernel/__snapshots__/ModelMapperTest__testMappingFromEntity with data set 2__1.json b/modules/agents/tests/src/Kernel/__snapshots__/ModelMapperTest__testMappingFromEntity with data set 2__1.json new file mode 100644 index 0000000000000000000000000000000000000000..e05ea7788a882750611eb61c3ba7fd89ad59ea1c --- /dev/null +++ b/modules/agents/tests/src/Kernel/__snapshots__/ModelMapperTest__testMappingFromEntity with data set 2__1.json @@ -0,0 +1,307 @@ +{ + "model_id": "eca_test_0009", + "label": "Set field values", + "version": null, + "description": "Set single and multi value fields with values, testing different variations.", + "events": [ + { + "element_id": "Event_056l2f4", + "plugin_id": "content_entity:presave", + "label": "Presave Node", + "configuration": { + "type": "node type_set_field_value" + }, + "successors": [ + { + "element_id": "Gateway_113xj72", + "condition": "Flow_0j7r2le" + }, + { + "element_id": "Gateway_0nagg07", + "condition": "Flow_1eoahw0" + }, + { + "element_id": "Gateway_1tbbhie", + "condition": "Flow_0e3yjwm" + }, + { + "element_id": "Gateway_1byzhmc", + "condition": "Flow_0wind58" + }, + { + "element_id": "Gateway_0aowp4i", + "condition": "Flow_0iwzr0t" + } + ] + } + ], + "conditions": [ + { + "element_id": "Flow_0j7r2le", + "plugin_id": "eca_entity_field_value", + "configuration": { + "negate": false, + "case": false, + "expected_value": "Append", + "field_name": "title", + "operator": "contains", + "type": "value", + "entity": "" + } + }, + { + "element_id": "Flow_1eoahw0", + "plugin_id": "eca_entity_is_new", + "configuration": { + "negate": false, + "entity": "" + } + }, + { + "element_id": "Flow_0e3yjwm", + "plugin_id": "eca_entity_field_value", + "configuration": { + "negate": false, + "case": false, + "expected_value": "Drop First", + "field_name": "title", + "operator": "contains", + "type": "value", + "entity": "" + } + }, + { + "element_id": "Flow_0wind58", + "plugin_id": "eca_entity_field_value", + "configuration": { + "negate": false, + "case": false, + "expected_value": "Reset", + "field_name": "title", + "operator": "contains", + "type": "value", + "entity": "" + } + }, + { + "element_id": "Flow_0iwzr0t", + "plugin_id": "eca_entity_field_value", + "configuration": { + "negate": false, + "case": false, + "expected_value": "Drop last", + "field_name": "title", + "operator": "contains", + "type": "value", + "entity": "" + } + } + ], + "gateways": [ + { + "gateway_id": "Gateway_113xj72", + "type": 0, + "successors": [ + { + "element_id": "Activity_0na1ecf", + "condition": "" + }, + { + "element_id": "Activity_1on1kw2", + "condition": "" + } + ] + }, + { + "gateway_id": "Gateway_1tbbhie", + "type": 0, + "successors": [ + { + "element_id": "Activity_03beihz", + "condition": "" + } + ] + }, + { + "gateway_id": "Gateway_0nagg07", + "type": 0, + "successors": [ + { + "element_id": "Activity_1agtxee", + "condition": "" + }, + { + "element_id": "Activity_13qlvkq", + "condition": "" + } + ] + }, + { + "gateway_id": "Gateway_1byzhmc", + "type": 0, + "successors": [ + { + "element_id": "Activity_0yt6yuv", + "condition": "" + } + ] + }, + { + "gateway_id": "Gateway_0aowp4i", + "type": 0, + "successors": [ + { + "element_id": "Activity_036vgtd", + "condition": "" + } + ] + } + ], + "actions": [ + { + "element_id": "Activity_1agtxee", + "plugin_id": "eca_set_field_value", + "label": "Set text line", + "configuration": { + "field_name": "field_text_line", + "field_value": "Title is [entity:title].", + "method": "set:clear", + "strip_tags": false, + "trim": false, + "save_entity": false, + "object": "" + }, + "successors": [] + }, + { + "element_id": "Activity_13qlvkq", + "plugin_id": "eca_set_field_value", + "label": "Set lines 1", + "configuration": { + "field_name": "field_text_lines", + "field_value": "Line 1", + "method": "set:clear", + "strip_tags": false, + "trim": false, + "save_entity": false, + "object": "" + }, + "successors": [] + }, + { + "element_id": "Activity_0na1ecf", + "plugin_id": "eca_set_field_value", + "label": "Overwrite text line", + "configuration": { + "field_name": "field_text_line", + "field_value": "The updated text line content.", + "method": "set:clear", + "strip_tags": false, + "trim": false, + "save_entity": false, + "object": "" + }, + "successors": [] + }, + { + "element_id": "Activity_1on1kw2", + "plugin_id": "eca_set_field_value", + "label": "Append line", + "configuration": { + "field_name": "field_text_lines", + "field_value": "Second line", + "method": "append:not_full", + "strip_tags": false, + "trim": false, + "save_entity": false, + "object": "" + }, + "successors": [ + { + "element_id": "Activity_0aa91q1", + "condition": "" + } + ] + }, + { + "element_id": "Activity_0aa91q1", + "plugin_id": "eca_set_field_value", + "label": "Append another line", + "configuration": { + "field_name": "field_text_lines", + "field_value": "Line 3", + "method": "append:not_full", + "strip_tags": false, + "trim": false, + "save_entity": false, + "object": "" + }, + "successors": [ + { + "element_id": "Activity_0zcaglk", + "condition": "" + } + ] + }, + { + "element_id": "Activity_0zcaglk", + "plugin_id": "eca_set_field_value", + "label": "Append another line", + "configuration": { + "field_name": "field_text_lines", + "field_value": "Line 4", + "method": "append:not_full", + "strip_tags": false, + "trim": false, + "save_entity": false, + "object": "" + }, + "successors": [] + }, + { + "element_id": "Activity_03beihz", + "plugin_id": "eca_set_field_value", + "label": "Append line", + "configuration": { + "field_name": "field_text_lines", + "field_value": "Line 4", + "method": "append:drop_first", + "strip_tags": false, + "trim": false, + "save_entity": false, + "object": "" + }, + "successors": [] + }, + { + "element_id": "Activity_0yt6yuv", + "plugin_id": "eca_set_field_value", + "label": "Reset lines", + "configuration": { + "field_name": "field_text_lines", + "field_value": "This is one line.", + "method": "set:clear", + "strip_tags": false, + "trim": false, + "save_entity": false, + "object": "" + }, + "successors": [] + }, + { + "element_id": "Activity_036vgtd", + "plugin_id": "eca_set_field_value", + "label": "Prepend line", + "configuration": { + "field_name": "field_text_lines", + "field_value": "Inserted line", + "method": "prepend:drop_last", + "strip_tags": false, + "trim": false, + "save_entity": false, + "object": "" + }, + "successors": [] + } + ] +}