maxRowsInTable = GeneralConfig::getIntegerConfigValue('datatable_archiving_maximum_rows_ai_chatbot_favoured_pages', 50000); $this->rankingQueryLimit = $this->getRankingQueryLimit($this->maxRowsInTable); } public function getRecordMetadata(ArchiveProcessor $archiveProcessor): array { $records = []; foreach ($this->variantByRecord() as $recordName => $variant) { // The discrepancy_score is table-relative (see FavouredPagesScorer), so it can't be summed // across child periods: sum the additive traffic, skip the score, then recompute it on the // aggregated union via the transform below. Core then truncates by the recomputed score. $records[] = Record::make(Record::TYPE_BLOB, $recordName) ->setColumnToSortByBeforeTruncation(Metrics::COLUMN_DISCREPANCY_SCORE) ->setBlobColumnAggregationOps([ Metrics::COLUMN_UNIQUE_HUMAN_PAGEVIEWS => 'sum', Metrics::COLUMN_AI_CHATBOT_REQUESTS => 'sum', Metrics::COLUMN_DISCREPANCY_SCORE => 'skip', ]) ->setAggregatedRecordTransform(function (DataTable $table) use ($variant): void { (new FavouredPagesScorer($variant))->addScores($table); }); } return $records; } public function isEnabled(ArchiveProcessor $archiveProcessor): bool { // don't process reports for any segment (these reports declare no segment support) return $archiveProcessor->getParams()->getSegment()->isEmpty(); } /** * Day archiving: build the human/AI union from the logs once, then materialise the per-variant * score on an independent copy for each record. Core truncates each by its score column. */ protected function aggregate(ArchiveProcessor $archiveProcessor): array { // Only the request count is needed here; the shared query's other metrics are for the Content report. $aiTable = $this->queryPageOrDocumentUrls($archiveProcessor, Action::TYPE_PAGE_URL, $this->rankingQueryLimit, [Metrics::COLUMN_REQUESTS]); // With no AI chatbot page requests there is nothing to favour against, so skip the (expensive) // human-pageviews scan and store empty records. if ($aiTable->getRowsCount() === 0) { Common::destroy($aiTable); return $this->emptyRecords(); } $humanTable = $this->queryHumanPageviews($archiveProcessor); $records = []; foreach ($this->variantByRecord() as $recordName => $variant) { // mergeHumanAndAiTables returns a fresh table each call, so scoring one variant cannot // affect the other. $table = self::mergeHumanAndAiTables($humanTable, $aiTable); (new FavouredPagesScorer($variant))->addScores($table); $records[$recordName] = $table; } Common::destroy($humanTable); Common::destroy($aiTable); return $records; } /** * @return array an empty record per variant */ private function emptyRecords(): array { $records = []; foreach (array_keys($this->variantByRecord()) as $recordName) { $records[$recordName] = new DataTable(); } return $records; } /** * Merges the human-pageviews and AI-requests tables (both keyed on `log_action.name`) into the * flat per-URL union: every URL on either side appears once, the missing side defaulting to 0. * * The two per-side truncation tails are combined into one "Others" summary row, left UNSCORED — a * discrepancy score over an aggregate of many URLs is meaningless — so the default exclude-low- * population filter hides it while it stays visible when that filter is off. * * Pure (DataTable in / out) so it is unit-testable without a database. */ public static function mergeHumanAndAiTables(DataTable $humanTable, DataTable $aiTable): DataTable { $byLabel = []; // The AI table comes from the shared page-metrics query, which names the request count // COLUMN_REQUESTS (the Content report's column); read it here as this report's AI requests. foreach ($aiTable->getRows() as $row) { if ($row->isSummaryRow()) { continue; } $label = (string) $row->getColumn('label'); if ($label === '') { continue; } $byLabel[$label] = [ Metrics::COLUMN_UNIQUE_HUMAN_PAGEVIEWS => 0, Metrics::COLUMN_AI_CHATBOT_REQUESTS => (int) $row->getColumn(Metrics::COLUMN_REQUESTS), ]; } foreach ($humanTable->getRows() as $row) { if ($row->isSummaryRow()) { continue; } $label = (string) $row->getColumn('label'); if ($label === '') { continue; } $human = (int) $row->getColumn(Metrics::COLUMN_UNIQUE_HUMAN_PAGEVIEWS); if (isset($byLabel[$label])) { $byLabel[$label][Metrics::COLUMN_UNIQUE_HUMAN_PAGEVIEWS] = $human; } else { $byLabel[$label] = [ Metrics::COLUMN_UNIQUE_HUMAN_PAGEVIEWS => $human, Metrics::COLUMN_AI_CHATBOT_REQUESTS => 0, ]; } } $table = new DataTable(); foreach ($byLabel as $label => $cols) { $table->addRow(new Row([ Row::COLUMNS => [ 'label' => (string) $label, Metrics::COLUMN_UNIQUE_HUMAN_PAGEVIEWS => $cols[Metrics::COLUMN_UNIQUE_HUMAN_PAGEVIEWS], Metrics::COLUMN_AI_CHATBOT_REQUESTS => $cols[Metrics::COLUMN_AI_CHATBOT_REQUESTS], ], ])); } self::addCombinedOthersRow($table, $humanTable, $aiTable); return $table; } /** * Combines the human and AI tables' "Others" summary rows (the truncated tail of each side) into a * single summary row on $table, with no discrepancy_score (see mergeHumanAndAiTables). Does nothing * when neither side was truncated. */ private static function addCombinedOthersRow(DataTable $table, DataTable $humanTable, DataTable $aiTable): void { $humanOthers = $humanTable->getSummaryRow(); $aiOthers = $aiTable->getSummaryRow(); if (!$humanOthers instanceof Row && !$aiOthers instanceof Row) { return; } $label = ($humanOthers instanceof Row ? $humanOthers : $aiOthers)->getColumn('label') ?: DataTable::LABEL_SUMMARY_ROW; $summary = new Row([ Row::COLUMNS => [ 'label' => $label, Metrics::COLUMN_UNIQUE_HUMAN_PAGEVIEWS => $humanOthers instanceof Row ? (int) $humanOthers->getColumn(Metrics::COLUMN_UNIQUE_HUMAN_PAGEVIEWS) : 0, Metrics::COLUMN_AI_CHATBOT_REQUESTS => $aiOthers instanceof Row ? (int) $aiOthers->getColumn(Metrics::COLUMN_REQUESTS) : 0, ], ]); $summary->setIsSummaryRow(); $table->addSummaryRow($summary); } /** * @return array record name => DiscrepancyScore variant */ private function variantByRecord(): array { return [ Archiver::AI_CHATBOTS_HUMAN_FAVOURED_PAGES_RECORD => DiscrepancyScore::VARIANT_HUMAN_FAVOURED, Archiver::AI_CHATBOTS_AI_FAVOURED_PAGES_RECORD => DiscrepancyScore::VARIANT_AI_FAVOURED, ]; } /** * Human pageviews (distinct visits, matching Actions' page `nb_visits`) per page URL, keyed on * `log_action.name` to merge directly with the AI-side counts. The `idaction_event_category IS * NULL` clause mirrors the Actions Pages report: a URL seen only as an event context is not a * pageview, so it must not be counted here. */ private function queryHumanPageviews(ArchiveProcessor $archiveProcessor): DataTable { $logAggregator = $archiveProcessor->getLogAggregator(); $where = $logAggregator->getWhereStatement('log_link_visit_action', 'server_time'); $actionTable = Common::prefixTable('log_action'); $visitActions = Common::prefixTable('log_link_visit_action'); $innerSql = sprintf( "SELECT log_action.name AS url, COUNT(DISTINCT log_link_visit_action.idvisit) AS %s FROM `%s` AS log_link_visit_action INNER JOIN `%s` AS log_action ON log_action.idaction = log_link_visit_action.idaction_url WHERE log_action.name IS NOT NULL AND log_action.name <> '' AND log_action.type = %d AND log_link_visit_action.idaction_event_category IS NULL AND %s GROUP BY log_action.name ORDER BY %s DESC", Metrics::COLUMN_UNIQUE_HUMAN_PAGEVIEWS, $visitActions, $actionTable, Action::TYPE_PAGE_URL, $where, Metrics::COLUMN_UNIQUE_HUMAN_PAGEVIEWS ); $rankingQuery = new RankingQuery($this->rankingQueryLimit); $rankingQuery->addLabelColumn('url'); $rankingQuery->addColumn(Metrics::COLUMN_UNIQUE_HUMAN_PAGEVIEWS, 'sum'); $wrappedSql = $rankingQuery->generateRankingQuery($innerSql); $stmt = Db::query($wrappedSql, $logAggregator->getGeneralQueryBindParams()); $table = new DataTable(); while ($row = $stmt->fetch()) { /** @var array $row */ $label = (string) $row['url']; $raw = $row[Metrics::COLUMN_UNIQUE_HUMAN_PAGEVIEWS] ?? 0; $table->sumRowWithLabel($label, [ Metrics::COLUMN_UNIQUE_HUMAN_PAGEVIEWS => is_numeric($raw) ? (int) $raw : 0, ]); } return $table; } }