Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ai_sorting.module
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions ai_sorting.services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
16 changes: 14 additions & 2 deletions js/ai-sorting-tracking.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
12 changes: 2 additions & 10 deletions scripts/run-drupal-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
143 changes: 143 additions & 0 deletions src/Decorator/AiSortingExperimentDecorator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php

namespace Drupal\ai_sorting\Decorator;

use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\rl\Decorator\ExperimentDecoratorInterface;
use Drupal\views\Views;

/**
* Experiment decorator for ai_sorting experiments.
*
* Provides human-readable entity labels for arm IDs in RL reports.
*/
class AiSortingExperimentDecorator implements ExperimentDecoratorInterface {

/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected EntityTypeManagerInterface $entityTypeManager;

/**
* Cache of view base entity types.
*
* @var array
*/
protected array $viewEntityTypeCache = [];

/**
* Constructs an AiSortingExperimentDecorator.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager) {
$this->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 }} <small>({{ id }})</small>',
'#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;
}

}
32 changes: 21 additions & 11 deletions src/Plugin/views/sort/AISorting.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -225,11 +232,11 @@ public function buildOptionsForm(&$form, FormStateInterface $form_state) {
'#title' => $this->t('AI Sorting Settings'),
'#open' => TRUE,
'#description' => $this->t('<strong>What does AI Sorting do?</strong><br>
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.<br><br>
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.<br><br>
<strong>How it works:</strong><br>
• <em>Turns</em>: When content appears in this view<br>
• <em>Rewards</em>: When users click on that content<br>
• The algorithm balances showing popular content with exploring new options.'),
• <em>Impressions</em>: When content appears in this view<br>
• <em>Conversions</em>: When users click on that content<br>
• The system balances showing popular content with exploring new options.'),
];

$form['ai_sorting_settings']['favor_recent'] = [
Expand Down Expand Up @@ -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,
];
}
Expand Down Expand Up @@ -317,17 +324,20 @@ 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]));
}
}
else {
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();
}

Expand Down
2 changes: 1 addition & 1 deletion src/Service/ExperimentRegistrationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class ExperimentRegistrationService {
*
* @var \Drupal\rl\Registry\ExperimentRegistryInterface
*/
protected $experimentRegistry;
protected ExperimentRegistryInterface $experimentRegistry;

/**
* Constructs a new ExperimentRegistrationService.
Expand Down