diff --git a/README.md b/README.md index 4f4e665..c5e22c1 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,31 @@ # AI Sorting -Intelligent content ordering for Drupal Views using machine learning. Content -automatically learns which items engage users most and surfaces the -best-performing content. +Intelligent ordering for Drupal Views using machine learning. Automatically learns which items engage users most and surfaces the best-performing content, regardless of entity type. ## Features -- Automatic content optimization based on user engagement -- Thompson Sampling machine learning algorithm -- Views integration as sort plugin -- Automatic cache management -- Real-time learning and adaptation +- **Universal entity support** - Works with nodes, users, taxonomy terms, media, custom entities, and external data +- **Thompson Sampling algorithm** - Advanced machine learning for exploration vs exploitation +- **Cold start handling** - New items get proper exploration scores automatically +- **Views integration** - Simple sort plugin that works with any Views-compatible data source +- **Real-time learning** - Continuous adaptation based on user interactions +- **Fail-hard debugging** - No silent fallbacks, issues are immediately visible ## Setup 1. Install the module (requires [RL module](https://www.drupal.org/project/rl)) -2. Edit any View display +2. Edit any View display (nodes, users, terms, media, custom entities) 3. Add "AI Sorting" as a sort criteria -4. Configure cache refresh rate (30 seconds to 10 minutes) -5. Save - content immediately begins learning from user interactions +4. Configure cache refresh rate and time window options +5. Save - items immediately begin learning from user interactions ## How It Works -1. **Track Engagement** - JavaScript monitors when content is viewed and clicked -2. **Learn Patterns** - Machine learning identifies high-performing content -3. **Optimize Order** - Best content automatically moves to prominent positions -4. **Continuous Improvement** - Performance gets better with every visitor +1. **Track Engagement** - JavaScript monitors when items are viewed and clicked +2. **Learn Patterns** - Thompson Sampling identifies high-performing items +3. **Optimize Order** - Best items automatically move to prominent positions +4. **Handle New Items** - New content gets exploration scores for fair exposure +5. **Continuous Improvement** - Performance gets better with every visitor ## Use Cases diff --git a/ai_sorting.libraries.yml b/ai_sorting.libraries.yml index bad7f68..8040d3d 100644 --- a/ai_sorting.libraries.yml +++ b/ai_sorting.libraries.yml @@ -1,15 +1,7 @@ -ai_sorting_turns: +ai_sorting_tracking: version: VERSION js: - js/ai-sorting-turns.js: {} - dependencies: - - core/drupal - - core/drupalSettings - -ai_sorting_rewards: - version: VERSION - js: - js/ai-sorting-rewards.js: {} + js/ai-sorting-tracking.js: {} dependencies: - core/drupal - core/drupalSettings diff --git a/ai_sorting.module b/ai_sorting.module index d0bfca4..8ae6aea 100644 --- a/ai_sorting.module +++ b/ai_sorting.module @@ -5,10 +5,9 @@ * Primary module file for AI Sorting. */ -use Drupal\node\NodeInterface; +use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Routing\RouteMatchInterface; use Drupal\views\ViewExecutable; -use Drupal\Core\Url; /** * Implements hook_help(). @@ -36,56 +35,82 @@ function ai_sorting_views_pre_render(ViewExecutable $view) { return; } - // Collect node IDs and URLs for JavaScript tracking. - $nids = []; - $nid_url_map = []; + // Get the base field for this view's data source. + $base_field = $view->storage->get('base_field'); + if (empty($base_field)) { + // Can't track without a unique identifier. + return; + } - foreach ($view->result as $index => $row) { + // Collect entity IDs and URLs for JavaScript tracking. + $entity_ids = []; + $entity_url_map = []; - $nid = NULL; + foreach ($view->result as $index => $row) { + $entity_id = NULL; + $entity = NULL; - // Try different ways to get the node ID. - if (isset($row->nid)) { - $nid = $row->nid; + // Try to get the entity ID from the base field. + if (isset($row->$base_field)) { + $entity_id = $row->$base_field; } - elseif (isset($row->node_field_data_nid)) { - $nid = $row->node_field_data_nid; - } - elseif (isset($row->_entity) && $row->_entity instanceof NodeInterface) { - $nid = $row->_entity->id(); + + // Try to get the entity object. + if (isset($row->_entity) && $row->_entity instanceof EntityInterface) { + $entity = $row->_entity; + if (!$entity_id) { + $entity_id = $entity->id(); + } } - elseif (isset($row->_object) && method_exists($row->_object, 'id')) { - $nid = $row->_object->id(); + elseif (isset($row->_object) && $row->_object instanceof EntityInterface) { + $entity = $row->_object; + if (!$entity_id) { + $entity_id = $entity->id(); + } } - if ($nid) { - $nids[] = $nid; - $nid_url_map[$nid] = Url::fromRoute('entity.node.canonical', - ['node' => $nid])->toString(); + if ($entity_id) { + $entity_ids[] = (string) $entity_id; + + // Generate URL for the entity if we have the entity object. + $url = ''; + if ($entity && $entity->hasLinkTemplate('canonical')) { + try { + $url = $entity->toUrl('canonical')->toString(); + } + catch (\Exception $e) { + // If URL generation fails, leave empty. + $url = ''; + } + } + + $entity_url_map[(string) $entity_id] = $url; } } - if (!empty($nids)) { + if (!empty($entity_ids)) { // Generate experiment UUID from view and display. $experiment_uuid = sha1($view->id() . ':' . $view->current_display); + // Create human-readable experiment name. + $experiment_name = $view->id() . ':' . $view->current_display; + // Register this experiment with the RL module. $registration_service = \Drupal::service( 'ai_sorting.experiment_registration' ); - $registration_service->registerExperiment($experiment_uuid); + $registration_service->registerExperiment($experiment_uuid, $experiment_name); // Get RL module path for optimized endpoint. $rl_path = \Drupal::service('extension.list.module')->getPath('rl'); $base_path = \Drupal::request()->getBasePath(); - // Attach JavaScript libraries and settings. - $view->element['#attached']['library'][] = 'ai_sorting/ai_sorting_turns'; - $view->element['#attached']['library'][] = 'ai_sorting/ai_sorting_rewards'; + // Attach JavaScript library and settings. + $view->element['#attached']['library'][] = 'ai_sorting/ai_sorting_tracking'; $view->element['#attached']['drupalSettings']['aiSorting']['views'][$view->id()] = [ 'experimentUuid' => $experiment_uuid, - 'nids' => $nids, - 'nidUrlMap' => $nid_url_map, + 'entityIds' => $entity_ids, + 'entityUrlMap' => $entity_url_map, 'rlEndpointUrl' => "{$base_path}/{$rl_path}/rl.php", 'displayId' => $view->current_display, ]; @@ -96,11 +121,28 @@ function ai_sorting_views_pre_render(ViewExecutable $view) { * Implements hook_views_data_alter(). */ function ai_sorting_views_data_alter(array &$data) { - $data['node']['ai_sorting'] = [ - 'title' => t('AI Sorting'), - 'help' => t('Sort content using reinforcement learning algorithm.'), - 'sort' => [ - 'id' => 'ai_sorting', - ], - ]; + // Add AI Sorting to all entity base tables. + $entity_type_manager = \Drupal::entityTypeManager(); + $entity_types = $entity_type_manager->getDefinitions(); + + foreach ($entity_types as $entity_type_id => $entity_type) { + // Only add to entity types that have a data table (appear in Views). + $data_table = $entity_type->getDataTable(); + $base_table = $entity_type->getBaseTable(); + + // Use data table if available, otherwise base table. + $table_name = $data_table ?: $base_table; + + if ($table_name && isset($data[$table_name])) { + $data[$table_name]['ai_sorting'] = [ + 'title' => t('AI Sorting'), + 'help' => t('Sort @entity_type using reinforcement learning algorithm.', [ + '@entity_type' => $entity_type->getLabel(), + ]), + 'sort' => [ + 'id' => 'ai_sorting', + ], + ]; + } + } } diff --git a/ai_sorting.permissions.yml b/ai_sorting.permissions.yml deleted file mode 100644 index 8cf3fa3..0000000 --- a/ai_sorting.permissions.yml +++ /dev/null @@ -1,3 +0,0 @@ -administer ai sorting: - title: 'Administer AI Sorting' - description: 'Configure AI sorting parameters in Views' diff --git a/ai_sorting.services.yml b/ai_sorting.services.yml index 51508ef..68aabd9 100644 --- a/ai_sorting.services.yml +++ b/ai_sorting.services.yml @@ -1,17 +1,4 @@ services: - ai_sorting.experiment_resolver: - class: Drupal\ai_sorting\Service\ExperimentResolver - - ai_sorting.total_trials_service: - class: Drupal\ai_sorting\Service\TotalTrialsService - arguments: ['@rl.experiment_manager'] - - ai_sorting.experiment_decorator: - class: Drupal\ai_sorting\Decorator\AiSortingExperimentDecorator - arguments: ['@entity_type.manager'] - tags: - - { name: rl_experiment_decorator } - ai_sorting.experiment_registration: class: Drupal\ai_sorting\Service\ExperimentRegistrationService arguments: ['@rl.experiment_registry'] diff --git a/docs/ai_sorting_project_desc.html b/docs/ai_sorting_project_desc.html deleted file mode 100644 index 903b755..0000000 --- a/docs/ai_sorting_project_desc.html +++ /dev/null @@ -1,98 +0,0 @@ -
This module is part of the AI module ecosystem and included in DXPR CMS.
- -Transform your content marketing with AI Sorting - the Drupal module that automatically learns which content engages your audience and promotes it for maximum impact. Stop guessing what works and let machine learning optimize your content visibility.- -
Unlike static content lists, AI Sorting delivers:
- -Spin up DXPR CMS—Drupal pre-configured with DXPR Builder, DXPR Theme, AI Sorting module, and security best practices.
- -AI Sorting is built on the powerful Reinforcement Learning (RL) module, which provides the core Thompson Sampling algorithm and experiment tracking capabilities. For developers building custom AI-powered features, the RL module offers a comprehensive API for multi-armed bandit experiments.
- -Ready to supercharge your content marketing? Install AI Sorting and turn your static content lists into intelligent, self-optimizing marketing tools that work 24/7 to maximize engagement.
\ No newline at end of file diff --git a/js/ai-sorting-rewards.js b/js/ai-sorting-rewards.js deleted file mode 100644 index bcb1d3d..0000000 --- a/js/ai-sorting-rewards.js +++ /dev/null @@ -1,47 +0,0 @@ -(function (Drupal, once) { - 'use strict'; - - Drupal.behaviors.aiSortingRewards = { - attach: function (context, settings) { - if (!settings.aiSorting || !settings.aiSorting.views) { - return; - } - - once('ai-sorting-rewards', '.view', context).forEach(function(view) { - var viewIdClass = Array.from(view.classList).find(cls => cls.startsWith('view-id-')); - var viewId = viewIdClass ? viewIdClass.replace('view-id-', '') : 'unknown'; - - if (settings.aiSorting.views[viewId]) { - var viewSettings = settings.aiSorting.views[viewId]; - var experimentUuid = viewSettings.experimentUuid; - var nidUrlMap = viewSettings.nidUrlMap; - var rlEndpointUrl = viewSettings.rlEndpointUrl; - - if (nidUrlMap && Object.keys(nidUrlMap).length > 0) { - var links = view.querySelectorAll('a'); - - links.forEach(function(link) { - var href = link.getAttribute('href'); - var nid = Object.keys(nidUrlMap).find(nid => nidUrlMap[nid] === href); - - if (nid) { - link.dataset.nid = nid; - - link.addEventListener('click', function() { - // Create FormData for POST request to rl.php - var formData = new FormData(); - formData.append('action', 'reward'); - formData.append('experiment_uuid', experimentUuid); - formData.append('arm_id', nid); - - // Use sendBeacon for non-blocking request - navigator.sendBeacon(rlEndpointUrl, formData); - }); - } - }); - } - } - }); - } - }; -})(Drupal, once); \ No newline at end of file diff --git a/js/ai-sorting-tracking.js b/js/ai-sorting-tracking.js new file mode 100644 index 0000000..ce76bfa --- /dev/null +++ b/js/ai-sorting-tracking.js @@ -0,0 +1,86 @@ +(function (Drupal, once) { + 'use strict'; + + Drupal.behaviors.aiSortingTracking = { + attach: function (context, settings) { + if (!settings.aiSorting || !settings.aiSorting.views) { + return; + } + + once('ai-sorting-tracking', '.view', context).forEach(function(view) { + var viewIdClass = Array.from(view.classList).find(cls => cls.startsWith('view-id-')); + var viewId = viewIdClass ? viewIdClass.replace('view-id-', '') : 'unknown'; + + if (!settings.aiSorting.views[viewId]) { + return; + } + + var viewSettings = settings.aiSorting.views[viewId]; + var experimentUuid = viewSettings.experimentUuid; + var entityIds = viewSettings.entityIds; + var entityUrlMap = viewSettings.entityUrlMap; + var rlEndpointUrl = viewSettings.rlEndpointUrl; + + // Fail hard if required data is missing + if (!experimentUuid || !rlEndpointUrl) { + throw new Error('AI Sorting: Missing required experiment data (experimentUuid or rlEndpointUrl)'); + } + + // Track turns (when view becomes visible) + if (entityIds && entityIds.length > 0) { + var observer = new IntersectionObserver(function(entries) { + if (entries[0].isIntersecting) { + // Create FormData for POST request to rl.php + var formData = new FormData(); + formData.append('action', 'turns'); + formData.append('experiment_uuid', experimentUuid); + formData.append('arm_ids', entityIds.join(',')); + + // Use sendBeacon for non-blocking request + var success = navigator.sendBeacon(rlEndpointUrl, formData); + if (!success) { + console.warn('AI Sorting: Failed to send turns data to RL endpoint'); + } + + observer.unobserve(view); + } + }, {threshold: 0.1}); + + observer.observe(view); + } else { + console.warn('AI Sorting: No entity IDs found for turns tracking'); + } + + // Track rewards (when links are clicked) + if (entityUrlMap && Object.keys(entityUrlMap).length > 0) { + var links = view.querySelectorAll('a'); + + links.forEach(function(link) { + var href = link.getAttribute('href'); + var entityId = Object.keys(entityUrlMap).find(id => entityUrlMap[id] === href); + + if (entityId) { + link.dataset.entityId = entityId; + + link.addEventListener('click', function() { + // Create FormData for POST request to rl.php + var formData = new FormData(); + formData.append('action', 'reward'); + formData.append('experiment_uuid', experimentUuid); + formData.append('arm_id', entityId); + + // Use sendBeacon for non-blocking request + var success = navigator.sendBeacon(rlEndpointUrl, formData); + if (!success) { + console.warn('AI Sorting: Failed to send reward data to RL endpoint for entity ' + entityId); + } + }); + } + }); + } else { + console.warn('AI Sorting: No entity URL map found for rewards tracking'); + } + }); + } + }; +})(Drupal, once); \ No newline at end of file diff --git a/js/ai-sorting-turns.js b/js/ai-sorting-turns.js deleted file mode 100644 index 105721d..0000000 --- a/js/ai-sorting-turns.js +++ /dev/null @@ -1,44 +0,0 @@ -(function (Drupal, once) { - 'use strict'; - - Drupal.behaviors.aiSortingTurns = { - attach: function (context, settings) { - if (!settings.aiSorting || !settings.aiSorting.views) { - return; - } - - once('ai-sorting-turns', '.view', context).forEach(function(view) { - var viewIdClass = Array.from(view.classList).find(cls => cls.startsWith('view-id-')); - var viewId = viewIdClass ? viewIdClass.replace('view-id-', '') : 'unknown'; - - if (settings.aiSorting.views[viewId]) { - var viewSettings = settings.aiSorting.views[viewId]; - var experimentUuid = viewSettings.experimentUuid; - var nids = viewSettings.nids; - var rlEndpointUrl = viewSettings.rlEndpointUrl; - - if (nids && nids.length > 0) { - - var observer = new IntersectionObserver(function(entries) { - if (entries[0].isIntersecting) { - - // Create FormData for POST request to rl.php - var formData = new FormData(); - formData.append('action', 'turns'); - formData.append('experiment_uuid', experimentUuid); - formData.append('arm_ids', nids.join(',')); - - // Use sendBeacon for non-blocking request - navigator.sendBeacon(rlEndpointUrl, formData); - - observer.unobserve(view); - } - }, {threshold: 0.1}); - - observer.observe(view); - } - } - }); - } - }; -})(Drupal, once); \ No newline at end of file diff --git a/src/Decorator/AiSortingExperimentDecorator.php b/src/Decorator/AiSortingExperimentDecorator.php deleted file mode 100644 index 3b035f4..0000000 --- a/src/Decorator/AiSortingExperimentDecorator.php +++ /dev/null @@ -1,84 +0,0 @@ -entityTypeManager = $entity_type_manager; - } - - /** - * {@inheritdoc} - */ - public function decorateExperiment(string $uuid): ?array { - // Check if this UUID follows the AI Sorting pattern (sha1 of view:display) - $view_storage = $this->entityTypeManager->getStorage('view'); - $views = $view_storage->loadMultiple(); - - foreach ($views as $view) { - /** @var \Drupal\views\ViewEntityInterface $view */ - foreach ($view->get('display') as $display_id => $display) { - $test_uuid = sha1($view->id() . ':' . $display_id); - if ($test_uuid === $uuid) { - // Found matching view and display. - $view_url = Url::fromRoute('entity.view.edit_form', ['view' => $view->id()]); - $view_link = Link::fromTextAndUrl($view->label(), $view_url); - - return [ - '#markup' => $view_link->toString() . ' (' . $display_id . ')', - ]; - } - } - } - - return NULL; - } - - /** - * {@inheritdoc} - */ - public function decorateArm(string $experiment_uuid, string $arm_id): ?array { - // For AI Sorting, arm_id should be a node ID. - if (is_numeric($arm_id)) { - try { - $node = $this->entityTypeManager->getStorage('node')->load($arm_id); - if ($node) { - $node_url = Url::fromRoute('entity.node.canonical', ['node' => $node->id()]); - $node_link = Link::fromTextAndUrl($node->label(), $node_url); - - return [ - '#markup' => $node_link->toString(), - ]; - } - } - catch (\Exception $e) { - // If node loading fails, fall back to raw arm_id. - } - } - - return NULL; - } - -} diff --git a/src/Plugin/views/sort/AISorting.php b/src/Plugin/views/sort/AISorting.php index 8b918d8..1511149 100644 --- a/src/Plugin/views/sort/AISorting.php +++ b/src/Plugin/views/sort/AISorting.php @@ -98,28 +98,79 @@ public function query() { $experiment_uuid = sha1($this->view->id() . ':' . $this->view->current_display); - // Only apply time window if "Favor recent content" is enabled. - $time_window_seconds = NULL; - if ($this->options['favor_recent']) { - $time_window_seconds = $this->options['time_window_seconds']; + $time_window_seconds = $this->options['favor_recent'] ? $this->options['time_window_seconds'] : NULL; + + // Get the base field from the view configuration. + $base_field = $this->view->storage->get('base_field'); + + // If no base field is defined, we can't sort. + if (empty($base_field)) { + throw new \RuntimeException('AI Sorting requires a base_field to be defined in the view.'); + } + + // Get all possible arm IDs that will be in the result set. + // We need to execute a query to get these IDs. + $arm_ids = []; + + // Clone the current query to get IDs without affecting the main query. + // The view's query at this point has all filters/conditions applied. + $id_query = clone $this->query; + + // We only need the base field (ID field). + // Clear fields and add only the base field. + $id_query->clearFields(); + $id_alias = $id_query->addField($this->tableAlias, $base_field); + + // Remove any existing grouping and ordering. + $id_query->groupby = []; + $id_query->orderby = []; + $id_query->addGroupBy($id_alias); + + // Build and execute the query to get all IDs. + // The query() method returns a SelectQuery object. + $query_obj = $id_query->query(); + + // Remove limit and offset from the query object. + $query_obj->range(); + + $result = $query_obj->execute(); + + foreach ($result as $row) { + if (isset($row->$base_field)) { + $arm_ids[] = (string) $row->$base_field; + } } + // Pass all arm IDs to the RL module to get scores. + // The RL module will handle new arms by initializing them. $scores = $this->experimentManager->getThompsonScores( $experiment_uuid, - $time_window_seconds + $time_window_seconds, + $arm_ids ); + // Fail hard if RL module doesn't return scores - no silent fallbacks! if (empty($scores)) { throw new \RuntimeException(sprintf( - 'No scores for experiment "%s". Check RL tracking.', + 'AI Sorting FAILED: No scores returned for experiment "%s". RL module must always return scores for requested arms. Check RL module configuration and database connectivity.', $experiment_uuid )); } - $case_statement = 'CASE ' . $this->tableAlias . '.nid '; - foreach ($scores as $nid => $score) { - $case_statement .= "WHEN " . (int) $nid . " THEN " . (float) $score . " "; + // Build the CASE statement for sorting. + $case_statement = 'CASE ' . $this->tableAlias . '.' . $base_field . ' '; + + foreach ($scores as $arm_id => $score) { + if (is_numeric($arm_id)) { + $case_statement .= "WHEN " . (int) $arm_id . " THEN " . (float) $score . " "; + } + else { + $escaped_id = addslashes($arm_id); + $case_statement .= "WHEN '" . $escaped_id . "' THEN " . (float) $score . " "; + } } + + // This should never be reached since we passed all IDs to RL module. $case_statement .= 'ELSE 0 END'; $this->query->addOrderBy( diff --git a/src/Service/ExperimentRegistrationService.php b/src/Service/ExperimentRegistrationService.php index 329bcf3..c26467a 100644 --- a/src/Service/ExperimentRegistrationService.php +++ b/src/Service/ExperimentRegistrationService.php @@ -31,9 +31,11 @@ public function __construct(ExperimentRegistryInterface $experiment_registry) { * * @param string $uuid * The experiment UUID to register. + * @param string $experiment_name + * Optional human-readable experiment name. */ - public function registerExperiment(string $uuid): void { - $this->experimentRegistry->register($uuid, 'ai_sorting'); + public function registerExperiment(string $uuid, ?string $experiment_name = NULL): void { + $this->experimentRegistry->register($uuid, 'ai_sorting', $experiment_name); } } diff --git a/src/Service/ExperimentResolver.php b/src/Service/ExperimentResolver.php deleted file mode 100644 index 0b8f200..0000000 --- a/src/Service/ExperimentResolver.php +++ /dev/null @@ -1,43 +0,0 @@ -experimentManager = $experiment_manager; - } - - /** - * Calculates and updates total trials for a view. - * - * @param \Drupal\views\ViewExecutable $view - * The view executable. - * - * @return int - * The total trials count. - */ - public function calculateAndUpdateTotalTrials(ViewExecutable $view) { - $experiment_uuid = sha1($view->id() . ':' . $view->current_display); - return $this->experimentManager->getTotalTurns($experiment_uuid); - } - - /** - * Gets total trials for a specific experiment. - * - * @param string $view_id - * The view ID. - * @param string $display_id - * The display ID. - * - * @return int - * The total trials count. - */ - public function getTotalTrials($view_id, $display_id) { - $experiment_uuid = sha1($view_id . ':' . $display_id); - return $this->experimentManager->getTotalTurns($experiment_uuid); - } - -}