File size: 9,972 Bytes
78185c4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 | <?php
/**
* Matomo - free/libre analytics platform
*
* @link https://matomo.org
* @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
*/
declare(strict_types=1);
namespace Piwik\Plugins\BotTracking\Reports;
use Piwik\Common;
use Piwik\DataTable;
use Piwik\Plugin\Report;
use Piwik\Plugin\ViewDataTable;
use Piwik\Plugins\Actions\Columns\PageUrl;
use Piwik\Plugins\BotTracking\Columns\Metrics\AIChatbotRequests;
use Piwik\Plugins\BotTracking\Columns\Metrics\DiscrepancyScore;
use Piwik\Plugins\BotTracking\Columns\Metrics\UniqueHumanPageviews;
use Piwik\Plugins\BotTracking\Metrics;
use Piwik\Report\ReportWidgetFactory;
use Piwik\Widget\WidgetsList;
/**
* Shared base for the Human-Favoured and AI-Favoured Pages reports.
*
* Both reports expose the same flat URL dimension, the same Unique Human Pageviews +
* AI Chatbot Requests metric pair, and a Discrepancy Score whose variant (human-favoured vs
* ai-favoured) is provided by the concrete subclass. Each variant is backed by its own archived
* blob record built and scored during archiving (see {@see \Piwik\Plugins\BotTracking\RecordBuilders\AIChatbotFavouredPages});
* the API just reads it. Report-level surfaces (Custom Alerts, Scheduled Reports, glossary) treat
* them as ordinary reports, and Row Evolution is supported because the per-period data is pre-computed.
*/
abstract class AbstractAIChatbotFavouredPagesReport extends Report
{
protected function init(): void
{
parent::init();
$this->categoryId = 'General_AIAssistants';
$this->subcategoryId = 'BotTracking_AIChatbotsContentRequests';
$this->dimension = new PageUrl();
// discrepancy_score is materialised during archiving (see AIChatbotFavouredPages /
// FavouredPagesScorer) and read straight back, so it is an ordinary column here rather than a
// recomputed processed metric. The two traffic metrics are ordered strong-side first (the
// column the report favours leads), so AI-Favoured leads with AI Chatbot Requests.
$human = new UniqueHumanPageviews();
$ai = new AIChatbotRequests();
$trafficMetrics = $this->getDiscrepancyScoreVariant() === DiscrepancyScore::VARIANT_AI_FAVOURED
? [$ai, $human]
: [$human, $ai];
$this->metrics = array_merge(
$trafficMetrics,
[new DiscrepancyScore($this->getDiscrepancyScoreVariant())]
);
// No processed metrics; don't inherit Report's core visitor defaults.
$this->processedMetrics = [];
// Both reports sort by the Discrepancy Score β that's the headline insight, and it already
// encodes traffic weighting, so sorting by it surfaces the genuinely (human/AI)-favoured
// pages rather than just the busiest ones.
$this->defaultSortColumn = Metrics::COLUMN_DISCREPANCY_SCORE;
}
/**
* @return DiscrepancyScore::VARIANT_HUMAN_FAVOURED|DiscrepancyScore::VARIANT_AI_FAVOURED
*/
abstract protected function getDiscrepancyScoreVariant(): string;
/**
* The report's strong-side traffic column (human pageviews for Human-Favoured, AI requests for
* AI-Favoured).
*/
private function getStrongSideColumn(): string
{
return $this->getDiscrepancyScoreVariant() === DiscrepancyScore::VARIANT_HUMAN_FAVOURED
? Metrics::COLUMN_UNIQUE_HUMAN_PAGEVIEWS
: Metrics::COLUMN_AI_CHATBOT_REQUESTS;
}
/**
* The two traffic columns ordered strong-side first, so each report leads with the metric it
* favours (AI Chatbot Requests for AI-Favoured, Unique Human Pageviews for Human-Favoured).
*
* @return string[]
*/
private function getTrafficColumnsInDisplayOrder(): array
{
return $this->getDiscrepancyScoreVariant() === DiscrepancyScore::VARIANT_AI_FAVOURED
? [Metrics::COLUMN_AI_CHATBOT_REQUESTS, Metrics::COLUMN_UNIQUE_HUMAN_PAGEVIEWS]
: [Metrics::COLUMN_UNIQUE_HUMAN_PAGEVIEWS, Metrics::COLUMN_AI_CHATBOT_REQUESTS];
}
/**
* Breaks ties on the Discrepancy Score by the report's strong-side traffic, so pages with the
* same score (notably the many score=0 rows when the low-population filter is off) are ordered
* by how much human / AI traffic they have instead of arbitrarily. If the user sorts by a
* different column, the score becomes the tie-breaker.
*/
public function getSecondarySortColumnCallback()
{
$strongColumn = $this->getStrongSideColumn();
return function ($primaryColumn) use ($strongColumn) {
return $primaryColumn === Metrics::COLUMN_DISCREPANCY_SCORE
? $strongColumn
: Metrics::COLUMN_DISCREPANCY_SCORE;
};
}
public function configureView(ViewDataTable $view): void
{
parent::configureView($view);
$view->config->setDefaultColumnsToDisplay(
array_merge(
['label'],
$this->getTrafficColumnsInDisplayOrder(),
[Metrics::COLUMN_DISCREPANCY_SCORE]
),
false,
false
);
// Show-all-columns switches to the Visitor Engagement preset, which doesn't fit this schema.
$view->config->show_table_all_columns = false;
// Insights and bar/pie/tag-cloud all assume visit metrics (nb_visits etc.) this report lacks,
// so they would render empty β the table is the only useful view.
$view->config->show_insights = false;
$view->config->show_bar_chart = false;
$view->config->show_pie_chart = false;
$view->config->show_tag_cloud = false;
// Render URL labels as clickable links (scheme-less normalized URLs; prepend https://).
$view->config->filters[] = function (DataTable $table) {
foreach ($table->getRows() as $row) {
if ($row->isSummaryRow()) {
continue;
}
$label = $row->getColumn('label');
if (is_string($label) && $label !== '') {
$row->setMetadata('url', 'https://' . $label);
}
}
};
// Force the default sort at the View layer too: RequestConfig::setDefaultSort ignores the
// Report's $defaultSortColumn β it falls back to nb_visits (absent here) and then to the
// first non-label column in columns_to_display. Set it explicitly so the Discrepancy Score
// is the default sort in the UI as well as via the API.
$view->requestConfig->filter_sort_column = $this->defaultSortColumn;
$view->requestConfig->filter_sort_order = 'desc';
$this->configureExcludeLowPopulation($view);
SegmentNotSupportedMessageHelper::addSegmentNotSupportedMessage($view);
}
/**
* Excludes pages that aren't meaningfully favoured by dropping rows whose Discrepancy Score is
* below 1. Because the score is `lean Γ volume`, this removes balanced and opposite-leaning
* pages (lean = 0 β score 0) as well as the near-zero-volume tail, so each report shows only
* pages leaning its own way. The toggle defaults to ON; `enable_filter_excludelowpop=0` shows
* every row.
*
* Wired through the standard ExcludeLowPopulation generic filter targeting the score column.
* The score is a stored column (materialised during archiving), so it is present on every row by
* the time the filter runs. The minimum value must stay > 0 β passing 0 makes ExcludeLowPopulation
* fall back to its 2%-of-sum heuristic and empty the table.
*/
private function configureExcludeLowPopulation(ViewDataTable $view): void
{
$view->config->show_exclude_low_population = true;
$enabled = Common::getRequestVar('enable_filter_excludelowpop', '1', 'string') !== '0';
// Surface the resolved toggle state to the client so the data-table footer label
// ("Include/Exclude Rows With Low Population") matches whether the filter is actually
// active on initial load, without the user having to interact with it first.
$view->config->custom_parameters['enable_filter_excludelowpop'] = $enabled ? '1' : '0';
if (!$enabled) {
return;
}
$view->requestConfig->filter_excludelowpop = Metrics::COLUMN_DISCREPANCY_SCORE;
$view->requestConfig->filter_excludelowpop_value = '1';
}
public function configureWidgets(WidgetsList $widgetsList, ReportWidgetFactory $factory): void
{
// Side-by-side layout contract: the Human-Favoured and AI-Favoured reports must
// render next to each other in a 2-column row on the AIChatbotsContentRequests page. Matomo
// has no 2-equal-column widget-container primitive, so this relies on the reporting page
// auto-pairing CONSECUTIVE NON-WIDE widgets into columns (see CoreHome
// ReportingPage.store::widgets). On this page the non-wide widgets are Documents, Broken,
// Human-Favoured and AI-Favoured (orders 20/30/40/50); the auto-pairing distributes them
// across two columns so Documents sits beside Broken and Human-Favoured beside AI-Favoured.
// For that to hold, all of the following must stay true (the BotTracking_spec.js pairing
// assertions are the load-bearing guard):
// - none of those four reports call setIsWide() (hence the bare createWidget() below);
// - they keep orders 40 and 50 so they stay paired with each other (after Documents/Broken);
// - the Pages report (order 10) stays wide so it remains a full-width row on top;
// - no other plugin injects a non-wide widget between/around them in this subcategory.
$widgetsList->addWidgetConfig($factory->createWidget());
}
}
|