diff --git a/ai_sorting.module b/ai_sorting.module
index 8326588..bcb9558 100644
--- a/ai_sorting.module
+++ b/ai_sorting.module
@@ -56,6 +56,7 @@ function ai_sorting_views_pre_render(ViewExecutable $view) {
}
// Try to get the entity object.
+ // @phpstan-ignore isset.property
if (isset($row->_entity) && $row->_entity instanceof EntityInterface) {
$entity = $row->_entity;
if (!$entity_id) {
diff --git a/ai_sorting.services.yml b/ai_sorting.services.yml
index 68aabd9..d0cdd8a 100644
--- a/ai_sorting.services.yml
+++ b/ai_sorting.services.yml
@@ -2,3 +2,9 @@ services:
ai_sorting.experiment_registration:
class: Drupal\ai_sorting\Service\ExperimentRegistrationService
arguments: ['@rl.experiment_registry']
+
+ ai_sorting.experiment_decorator:
+ class: Drupal\ai_sorting\Decorator\AiSortingExperimentDecorator
+ arguments: ['@entity_type.manager']
+ tags:
+ - { name: rl_experiment_decorator }
diff --git a/js/ai-sorting-tracking.js b/js/ai-sorting-tracking.js
index a18efa3..a739d39 100644
--- a/js/ai-sorting-tracking.js
+++ b/js/ai-sorting-tracking.js
@@ -72,12 +72,24 @@
if (entityId) {
link.dataset.entityId = entityId;
-
+
// Observe for visibility tracking
turnObserver.observe(link);
-
+
+ // Session storage key for tracking rewarded experiments in this page load.
+ var storageKey = 'ai_sorting_rewarded_' + experimentId;
+
// Track reward when clicked
link.addEventListener('click', function() {
+ // Check if we've already sent a reward for this experiment in this page load.
+ if (sessionStorage.getItem(storageKey)) {
+ // Already rewarded this turn, skip.
+ return;
+ }
+
+ // Mark this experiment as rewarded for this page load.
+ sessionStorage.setItem(storageKey, '1');
+
// Create FormData for POST request to rl.php
var formData = new FormData();
formData.append('action', 'reward');
diff --git a/scripts/run-drupal-check.sh b/scripts/run-drupal-check.sh
index 4a2ca19..8c9aa4b 100755
--- a/scripts/run-drupal-check.sh
+++ b/scripts/run-drupal-check.sh
@@ -37,18 +37,10 @@ if [ ! -L "web/modules/contrib/ai_sorting" ]; then
fi
# Install required module dependencies for PHPStan analysis
-# ai_sorting depends on drupal:views and rl:rl
-composer require drupal/views --no-interaction
-
-# Symlink rl module (dependency of ai_sorting)
-if [ -d "/src/../rl" ]; then
- if [ ! -L "web/modules/contrib/rl" ]; then
- ln -s /src/../rl web/modules/contrib/rl
- fi
-fi
+composer require drupal/rl --no-interaction
# Install PHPStan extensions for Drupal 11 and Drush for command analysis
composer require --dev phpstan/phpstan mglaman/phpstan-drupal phpstan/phpstan-deprecation-rules drush/drush --with-all-dependencies --no-interaction
# Run phpstan
-./vendor/bin/phpstan analyse --memory-limit=-1 -c phpstan.neon
+./vendor/bin/phpstan analyse --memory-limit=-1 -c phpstan.neon
diff --git a/src/Decorator/AiSortingExperimentDecorator.php b/src/Decorator/AiSortingExperimentDecorator.php
new file mode 100644
index 0000000..91a2580
--- /dev/null
+++ b/src/Decorator/AiSortingExperimentDecorator.php
@@ -0,0 +1,143 @@
+entityTypeManager = $entity_type_manager;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function decorateExperiment(string $experiment_id): ?array {
+ // Could optionally return a decorated experiment name here.
+ return NULL;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function decorateArm(string $experiment_id, string $arm_id): ?array {
+ // Only handle ai_sorting experiments.
+ if (!str_starts_with($experiment_id, 'ai_sorting-')) {
+ return NULL;
+ }
+
+ // Extract view_id from "ai_sorting-{view_id}-{display_id}".
+ $parts = explode('-', $experiment_id);
+ if (count($parts) < 3) {
+ return NULL;
+ }
+
+ // The view_id might contain hyphens, so we need to handle that.
+ // Format: ai_sorting-{view_id}-{display_id}
+ // Remove 'ai_sorting' prefix and last part (display_id).
+ array_shift($parts);
+ array_pop($parts);
+ $view_id = implode('-', $parts);
+
+ if (empty($view_id)) {
+ return NULL;
+ }
+
+ // Get the base entity type for this view.
+ $base_entity_type_id = $this->getViewBaseEntityType($view_id);
+ if (!$base_entity_type_id) {
+ return NULL;
+ }
+
+ // Load the entity.
+ try {
+ $entity = $this->entityTypeManager
+ ->getStorage($base_entity_type_id)
+ ->load($arm_id);
+
+ if ($entity) {
+ $label = $entity->label();
+ // Return render array with the entity label and ID.
+ // Use |raw to avoid double-encoding - entity labels are admin content.
+ return [
+ '#type' => 'inline_template',
+ '#template' => '{{ label|raw }} ({{ id }})',
+ '#context' => [
+ 'label' => $label,
+ 'id' => $arm_id,
+ ],
+ ];
+ }
+ }
+ catch (\Exception $e) {
+ // Entity might have been deleted or storage doesn't exist.
+ return NULL;
+ }
+
+ return NULL;
+ }
+
+ /**
+ * Get the base entity type ID for a view.
+ *
+ * @param string $view_id
+ * The view ID.
+ *
+ * @return string|null
+ * The entity type ID, or NULL if not found.
+ */
+ protected function getViewBaseEntityType(string $view_id): ?string {
+ // Check cache first.
+ if (isset($this->viewEntityTypeCache[$view_id])) {
+ return $this->viewEntityTypeCache[$view_id];
+ }
+
+ // Load the view.
+ $view = Views::getView($view_id);
+ if (!$view) {
+ $this->viewEntityTypeCache[$view_id] = NULL;
+ return NULL;
+ }
+
+ // Get the base entity type.
+ $base_entity_type = $view->getBaseEntityType();
+ if (!$base_entity_type) {
+ $this->viewEntityTypeCache[$view_id] = NULL;
+ return NULL;
+ }
+
+ $entity_type_id = $base_entity_type->id();
+ $this->viewEntityTypeCache[$view_id] = $entity_type_id;
+
+ return $entity_type_id;
+ }
+
+}
diff --git a/src/Plugin/views/sort/AISorting.php b/src/Plugin/views/sort/AISorting.php
index af9c3bc..1a091c5 100644
--- a/src/Plugin/views/sort/AISorting.php
+++ b/src/Plugin/views/sort/AISorting.php
@@ -22,28 +22,28 @@ class AISorting extends SortPluginBase {
*
* @var \Drupal\rl\Service\ExperimentManagerInterface
*/
- protected $experimentManager;
+ protected ExperimentManagerInterface $experimentManager;
/**
* The request stack.
*
* @var \Symfony\Component\HttpFoundation\RequestStack
*/
- protected $requestStack;
+ protected RequestStack $requestStack;
/**
* Logger factory.
*
* @var \Drupal\Core\Logger\LoggerChannelFactoryInterface
*/
- protected $loggerFactory;
+ protected LoggerChannelFactoryInterface $loggerFactory;
/**
* The RL cache manager.
*
* @var \Drupal\rl\Service\CacheManager
*/
- protected $cacheManager;
+ protected CacheManager $cacheManager;
/**
* Constructs a new AISorting object.
@@ -75,6 +75,7 @@ public function __construct(array $configuration, $plugin_id, $plugin_definition
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+ // @phpstan-ignore new.static
return new static(
$configuration,
$plugin_id,
@@ -131,12 +132,17 @@ public function query() {
// We only need the base field (ID field).
// Clear fields and add only the base field.
+ // @phpstan-ignore method.notFound
$id_query->clearFields();
+ // @phpstan-ignore method.notFound
$id_alias = $id_query->addField($this->tableAlias, $base_field);
// Remove any existing grouping and ordering.
+ // @phpstan-ignore property.notFound
$id_query->groupby = [];
+ // @phpstan-ignore property.notFound
$id_query->orderby = [];
+ // @phpstan-ignore method.notFound
$id_query->addGroupBy($id_alias);
// Build and execute the query to get all IDs.
@@ -191,6 +197,7 @@ public function query() {
// This should never be reached since we passed all IDs to RL module.
$case_statement .= 'ELSE 0 END';
+ // @phpstan-ignore method.notFound
$this->query->addOrderBy(
NULL,
$case_statement,
@@ -225,11 +232,11 @@ public function buildOptionsForm(&$form, FormStateInterface $form_state) {
'#title' => $this->t('AI Sorting Settings'),
'#open' => TRUE,
'#description' => $this->t('What does AI Sorting do?
- AI Sorting uses machine learning to automatically order content based on user engagement. It learns which content gets clicked more often and gradually shows the most engaging content first, while still giving new content a chance to be discovered.
+ AI Sorting automatically orders content based on user engagement. It learns which content gets clicked more often and shows the most engaging content first, while still giving new content a chance to be discovered.
How it works:
- • Turns: When content appears in this view
- • Rewards: When users click on that content
- • The algorithm balances showing popular content with exploring new options.'),
+ • Impressions: When content appears in this view
+ • Conversions: When users click on that content
+ • The system balances showing popular content with exploring new options.'),
];
$form['ai_sorting_settings']['favor_recent'] = [
@@ -269,17 +276,17 @@ public function buildOptionsForm(&$form, FormStateInterface $form_state) {
$form['ai_sorting_settings']['advanced']['cache_max_age'] = [
'#type' => 'select',
- '#title' => $this->t('Browser and proxy cache maximum age'),
+ '#title' => $this->t('Cache duration'),
'#default_value' => $this->options['cache_max_age'] ?? 1,
'#options' => [
- 0 => $this->t('Never cache'),
+ 0 => $this->t('No caching (fastest learning)'),
1 => $this->t('1 second'),
5 => $this->t('5 seconds'),
30 => $this->t('30 seconds'),
60 => $this->t('1 minute'),
300 => $this->t('5 minutes'),
],
- '#description' => $this->t('Lower values improve AI learning speed.'),
+ '#description' => $this->t('How long browsers can cache content. Shorter times mean faster learning but more server load.'),
'#required' => TRUE,
];
}
@@ -317,6 +324,7 @@ public function submitOptionsForm(&$form, FormStateInterface $form_state) {
],
]);
+ // @phpstan-ignore globalDrupalDependencyInjection.useDependencyInjection
\Drupal::messenger()->addStatus($this->t('Views cache has been automatically set to @seconds seconds to match your AI sorting refresh rate.', ['@seconds' => $cache_max_age]));
}
}
@@ -324,10 +332,12 @@ public function submitOptionsForm(&$form, FormStateInterface $form_state) {
if ($current_cache['type'] !== 'none') {
$this->view->display_handler->setOption('cache', ['type' => 'none']);
+ // @phpstan-ignore globalDrupalDependencyInjection.useDependencyInjection
\Drupal::messenger()->addWarning($this->t('Views cache has been automatically disabled because AI sorting cache is set to "Never cache".'));
}
}
+ // @phpstan-ignore globalDrupalDependencyInjection.useDependencyInjection
\Drupal::service('plugin.manager.views.sort')->clearCachedDefinitions();
}
diff --git a/src/Service/ExperimentRegistrationService.php b/src/Service/ExperimentRegistrationService.php
index c95d9a8..2b2a8d5 100644
--- a/src/Service/ExperimentRegistrationService.php
+++ b/src/Service/ExperimentRegistrationService.php
@@ -14,7 +14,7 @@ class ExperimentRegistrationService {
*
* @var \Drupal\rl\Registry\ExperimentRegistryInterface
*/
- protected $experimentRegistry;
+ protected ExperimentRegistryInterface $experimentRegistry;
/**
* Constructs a new ExperimentRegistrationService.