From 836226870bf82d817fe27507ed3206411060ad6d Mon Sep 17 00:00:00 2001 From: Jurriaan Roelofs Date: Tue, 4 Nov 2025 15:02:59 +0100 Subject: [PATCH 1/2] Fix drupal-check compliance and improve code quality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add typed properties for all injected services (PHP 8.0+ syntax) - Fix drupal-check script to use composer require drupal/rl instead of local mounting for consistent cross-environment testing - Add @phpstan-ignore comments for standard Drupal patterns: - Views QueryPluginBase SQL-specific methods - Views ResultRow dynamic properties - new static() in dependency injection - Drupal static calls in form context - Pass all linters with 0 errors, 0 warnings 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- ai_sorting.module | 1 + js/ai-sorting-tracking.js | 16 +++++++++++-- scripts/run-drupal-check.sh | 12 ++-------- src/Plugin/views/sort/AISorting.php | 24 +++++++++++++------ src/Service/ExperimentRegistrationService.php | 2 +- 5 files changed, 35 insertions(+), 20 deletions(-) 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/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/Plugin/views/sort/AISorting.php b/src/Plugin/views/sort/AISorting.php index af9c3bc..4ac3ef4 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, @@ -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. From 236435a28f7d5c59f0ff6d7d0dc20afca1ff0f0e Mon Sep 17 00:00:00 2001 From: Jurriaan Roelofs Date: Mon, 26 Jan 2026 12:34:38 +0100 Subject: [PATCH 2/2] feat(ui): optimize terminology for content marketers Update UI strings to be more accessible to content marketers: - Change "Turns" to "Impressions" for content visibility tracking - Change "Rewards" to "Conversions" for click tracking - Change "algorithm" to "system" for less technical language - Remove "machine learning" from description Add experiment decorator for human-readable labels: - Create AiSortingExperimentDecorator service - Register decorator in ai_sorting.services.yml with rl.experiment_decorator tag - Decorator provides entity labels for arm IDs from view base entity type - Shows entity title with ID in parentheses in RL reports Fixes #16 --- ai_sorting.services.yml | 6 + .../AiSortingExperimentDecorator.php | 143 ++++++++++++++++++ src/Plugin/views/sort/AISorting.php | 8 +- 3 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 src/Decorator/AiSortingExperimentDecorator.php 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/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 4ac3ef4..1a091c5 100644 --- a/src/Plugin/views/sort/AISorting.php +++ b/src/Plugin/views/sort/AISorting.php @@ -232,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'] = [