MDL Shield

AI Activity Filter

local_activityfilter

Print Report
Plugin Information

local_activityfilter (release 2.0.0) is a Moodle local plugin that adds an AI-driven activity search modal to the course "add activity" dropdown. A teacher types a free-form prompt (e.g. "I want students to peer-review each other's work"), the plugin assembles a description of all installed activity plugins, ships the combined prompt to the core AI subsystem via core_ai\manager::process_action() with a generate_text action, parses the JSON response, and renders a ranked list of recommended activities with star ratings, usage frequency, hint and reason fields. It exposes two AJAX web services (local_activityfilter_filter_activities, local_activityfilter_get_max_content_item_occurrence), wires services through core\di via the di_configuration hook, and injects its frontend through the before_html_attributes hook. A dummy_mode setting enables a static fixture for development. Two third-party libraries are bundled (nlp-tools, voku/stop-words) and used to remove stop-words from the user prompt before it is sent to the model.

Privacy API
Unit Tests
Behat Tests
Reviewed:2026-05-21
191 files·21,473 lines
Grade Justification

The plugin follows the main Moodle security primitives correctly: every web service entry point calls validate_parameters(), validate_context() and require_capability(); both SQL queries use placeholders; no superglobals, no shell or eval, no raw filesystem or HTTP I/O, and no custom DB tables to misuse. Mustache templates use double-brace auto-escaping for all AI-supplied fields and only render the activity icon HTML unescaped (which is sourced from core content_item::get_icon(), not the AI).

The one medium finding is that the plugin invokes the AI subsystem without first confirming that the user has accepted the AI usage policy — the documented pattern used by core placements (e.g. aiplacement_courseassist) is to gate the call on core_ai/policy.getPolicyStatus. This is partially mitigated by the teacher/editing-teacher capability gate and by the fact that all calls are still logged by core in ai_action_register, but it does bypass an intended consent control.

The remainder of the findings are low-severity code-quality and compliance issues: a stale/non-existent use core\test\handler_one import, two near-duplicate description-helper classes, hardcoded German strings that should be language strings, an under-specified $plugin->requires (2022112800 / Moodle 4.1 while the code uses Moodle 4.5+ APIs such as core\di, di_configuration and core_ai\manager), a fragile regex/str_replace pass over JSON-encoded prompt data, require instead of require_once for the Composer autoload, a wide-scope MutationObserver on document.body with subtree: true, and an inefficient get_record_sql(..., IGNORE_MULTIPLE) pattern that fetches every grouped row to obtain a single maximum. Taken together these point to a pattern of small quality gaps rather than security risk.

AI Summary

Overview

local_activityfilter 2.0.0 is a Moodle 5.x local plugin that adds an AI-powered activity recommendation modal to the course "add activity" dropdown. It is a relatively small, well-structured codebase: ~15 PHP source files plus AMD modules, Mustache templates and two bundled third-party libraries (nlp-tools, voku/stop-words) that are properly declared in thirdpartylibs.xml.

What is good

  • Both web services (filter_activities, get_max_content_item_occurrence) follow the canonical pattern: validate_parameters(), validate_context(), then require_capability() against a course-level capability defined in db/access.php.
  • The only two SQL statements use named placeholders via $DB->count_records_sql() / $DB->get_record_sql() — no string concatenation of user input.
  • No $_GET / $_POST / $_REQUEST / $_SESSION usage anywhere; all input flows through the external API parameter system.
  • No eval, exec, shell_exec, proc_open (the lone hit in vendor is in an unused ExternalMaxentOptimizer class), and no raw HTTP / filesystem I/O — the only file_get_contents call reads a bundled dummydata.json via __DIR__.
  • Mustache templates escape every AI-supplied field ({{pluginname}}, {{hint}}, {{reason}}, {{occurrences}}). The single unescaped {{{activityicon}}} is fed only from core content_item::get_icon(), never from the AI response (the JS in content_item_ranking.js and the PHP in ai_searcher::convert_json_to_ranking overwrite it from activity_data::get_logo_html()).
  • A privacy provider exists (null_provider) and the German/English language strings acknowledge that user data is forwarded to the AI provider.
  • A reasonable PHPUnit test suite covers the AI response parser and the activity summarizer.

Key issues

  • AI policy acceptance is not enforced. The plugin happily sends user prompts to core_ai\manager::process_action() without ever calling core_ai/policy.getPolicyStatus on the frontend (compare with aiplacement_courseassist). This is the only medium finding.
  • A handful of low-severity code-quality issues: a dead use core\test\handler_one import that refers to a class that does not exist in core, two near-duplicate helper classes (plugin_description and overwritten_content_item_description), hardcoded German default descriptions inside PHP instead of language strings, $plugin->requires = 2022112800 (Moodle 4.1) despite using 4.5+ APIs (core\di, di_configuration, core_ai\manager), and a fragile regex/str_replace pass over JSON-encoded prompt data.
  • Smaller infrastructure issues: require rather than require_once for the Composer autoload, an inefficient ORDER BY COUNT(*) DESC + IGNORE_MULTIPLE pattern in get_max_content_item_occurrence, a site-wide MutationObserver on document.body with subtree: true, and the fact that get_max_content_item_occurrence reports site-aggregate counts but its capability is checked only at course level.

Bottom line

The plugin is functional, defensively coded against the common Moodle pitfalls, and ships with tests and CI. There are no critical or high-severity vulnerabilities. The single medium finding around AI policy enforcement, combined with the pattern of low-severity code-quality slips, lands it in the B range — close to B+ but pulled down by the volume of cleanup items.

Findings

complianceMedium
AI usage policy is never checked before sending prompts

The plugin sends arbitrary user prompts to the core AI subsystem via core_ai\manager::process_action() without first verifying that the user has accepted the site's AI usage policy.

The canonical flow used by core AI placements (for example aiplacement_courseassist/placement.js) is to call core_ai/policy.getPolicyStatus() on the frontend, present the policy modal when the user has not yet accepted, and only then dispatch the AI request. local_activityfilter skips this step entirely: the modal opens, the user types a prompt, and the request goes straight through fetchContentItemsRankinglocal_activityfilter_filter_activitiescore_ai\manager::process_action().

While the request is still logged in ai_action_register and is gated by a teacher-level capability, the user is never shown the AI usage policy and never asked to consent — bypassing a control that the AI subsystem deliberately exposes to callers.

Risk Assessment

Medium risk. No attacker exploitation here — the consequence is that a legitimate user (a teacher with the filter_activities capability) sends their prompt to the configured external AI provider without ever being shown the data-handling policy. This is a privacy / consent compliance gap rather than an authorization break: a teacher could not have used the feature without the capability anyway, and the request is still logged in ai_action_register. The blast radius is bounded to the teacher's own data, and the only data sent is what they type into the search box plus the static plugin-description summary. Severity sits at medium because the intended Moodle consent control is bypassed on every call.

Context

Moodle 4.5 introduced an AI subsystem that allows admins to attach external LLM providers (OpenAI, Azure, Anthropic, …). Because user prompts are forwarded to third parties, the subsystem also ships a policy modal (core_ai/policy) that must be shown to each user before their first AI action. Core placements such as aiplacement_courseassist enforce this in JS by calling Policy.getPolicyStatus(userId) before any request. local_activityfilter reuses the same underlying manager::process_action() API but skips this consent layer in both the JS modal and the server-side web service.

classes/external/filter_activities.php:44Source link unavailable — plugin was reviewed from zip without a matching git ref
Affected Code
public static function execute(int $courseid, string $prompt): array {
    $params = self::validate_parameters(self::execute_parameters(), [
        'prompt' => $prompt,
        'courseid' => $courseid,
    ]);

    $ctx = context_course::instance($params['courseid']);
    self::validate_context($ctx);
    require_capability('local/activityfilter:filter_activities', $ctx);

    $activitysearcher = di::get(i_activity_searcher::class);
    return $activitysearcher->filter_activities($params['prompt']);
}
Suggested Fix

Either guard the web service call server-side by checking core_ai\manager::get_user_policy_status($USER->id) and throwing moodle_exception when it is false, or check the policy from the JS modal before issuing the AJAX request:

import Policy from 'core_ai/policy';
// ...
if (!await Policy.getPolicyStatus(M.cfg.userId)) {
    await Policy.acceptPolicy(); // shows the modal and records acceptance
}
const response = await fetchContentItemsRanking(prompt);

The server-side guard is the more robust option because the web service can be reached without going through this plugin's own JS.

amd/src/content_item_filter_modal.js:46Source link unavailable — plugin was reviewed from zip without a matching git ref
Affected Code
async function search(modalRoot) {
    const results = modalRoot.querySelector(Selectors.resultArea);
    if (!results) {
        window.console.error(`Result area ${Selectors.resultArea} could not be found.`);
        return;
    }

    const prompt = getSearchPrompt(modalRoot);
    const response = await fetchContentItemsRanking(prompt);
Suggested Fix

Before calling fetchContentItemsRanking, check Policy.getPolicyStatus and surface the acceptance UI when the user has not yet accepted the site's AI policy.

code qualityLow
Dead `use core\test\handler_one` import referencing a non-existent core class

activity_data.php imports core\test\handler_one, but that class does not exist anywhere in Moodle core (find /moodle -name 'handler_one*' returns nothing) and the symbol is not referenced in the file. It is a leftover from earlier development.

The use statement itself does not error because PHP only resolves the alias when something tries to reference handler_one — but it pollutes the symbol table, can confuse static analysers, and is misleading to readers.

Risk Assessment

Low risk. Pure code-quality issue. No runtime impact unless somebody adds code that actually references handler_one, at which point class autoloading will fail.

Context

activity_data is a value object that wraps a core content_item and provides JSON serialization for AI prompting. The import was likely added during development and never removed.

classes/activity_searcher/activity_data.php:20Source link unavailable — plugin was reviewed from zip without a matching git ref
Identified Code
use core\di;
use core\test\handler_one;
use core_course\local\entity\content_item;
Suggested Fix

Delete the line:

use core\test\handler_one;
code qualityLow
`$plugin->requires` is set to Moodle 4.1 but the code requires Moodle 4.5+

version.php declares $plugin->requires = 2022112800; (Moodle 4.1) yet the implementation depends on APIs that did not exist before Moodle 4.5:

  • core\di::get(...) (Moodle 4.5)
  • The core\hook\di_configuration hook (Moodle 4.5)
  • The core\hook\output\before_html_attributes hook (Moodle 4.5)
  • core_ai\manager and core_ai\aiactions\generate_text (Moodle 4.5)

Installing the plugin onto a 4.1–4.4 site will fail at runtime because these classes do not exist.

Risk Assessment

Low risk. No security impact, but causes a confusing fatal error if an admin installs the plugin onto a supported-looking-but-too-old Moodle.

Context

$plugin->requires is what Moodle uses to decide whether to allow installation on a given site. Setting it too low silently allows installation onto a Moodle that cannot run the plugin.

version.php:29Source link unavailable — plugin was reviewed from zip without a matching git ref
Identified Code
$plugin->component = 'local_activityfilter';
$plugin->release = '2.0.0';
$plugin->version = 2026042000;
$plugin->requires = 2022112800;
$plugin->maturity = MATURITY_STABLE;
Suggested Fix

Bump $plugin->requires to at least the Moodle 4.5 release date (2024100700) so installation is blocked on older sites where the AI subsystem and core\di are absent.

code qualityLow
Hardcoded German default descriptions in PHP instead of language strings

Two helper classes contain multi-sentence German default descriptions for the booking and mootimeter activity plugins, hardcoded inline. These strings are user-visible (they are what the admin sees in the settings page and what gets shipped to the LLM as the plugin description), and they exist in only one language.

The duplicated text in both classes also means a translation fix would have to be made in two files.

Risk Assessment

Low risk. No security impact. This is a Moodle coding-standard / i18n issue: any user-visible string should live in a language file so it can be translated.

Context

Both classes provide the description text that is either shown to the admin as the default value for an ai_hint_* setting, or sent to the LLM as the plugin description in activity_data::jsonSerialize().

classes/local/overwritten_content_item_description.php:52Source link unavailable — plugin was reviewed from zip without a matching git ref
Identified Code
public static function get_overwritten_default(string $pluginname): string|false {
    if ($pluginname == 'booking') {
        return 'Mit Buchung können Teilnehmer/innen Termine, ' .
            'Veranstaltungen oder Ressourcen eigenständig reservieren. ' .
            'Trainer/innen legen dafür Zeitfenster oder Optionen fest und ' .
            'behalten den Überblick über alle Buchungen. Diese Aktivität eignet ' .
            'sich beispielsweise für Elternsprechtage oder Projekttermine.';
    }

    if ($pluginname == 'mootimeter') {
        return 'Mit Mootimeter können interaktive Umfragen, ' .
            ...
    }

    return false;
}
Suggested Fix

Move the German text into lang/de/local_activityfilter.php (and a fallback English version into lang/en/local_activityfilter.php) under keys such as default_hint_booking and default_hint_mootimeter, and use get_string() to fetch them.

classes/local/plugin_description.php:52Source link unavailable — plugin was reviewed from zip without a matching git ref
Identified Code
public static function get_default(string $pluginname): string {
    if ($pluginname == 'booking') {
        return 'Mit Buchung können Teilnehmer/innen Termine, ' .
            ...
    }

    if ($pluginname == 'mootimeter') {
        return 'Mit Mootimeter können interaktive Umfragen, ' .
            ...
    }

    return get_string('modulename_help', $pluginname);
}
Suggested Fix

Same approach — replace the inline strings with get_string() calls.

code qualityLow
`plugin_description` and `overwritten_content_item_description` are near-duplicate classes

classes/local/plugin_description.php and classes/local/overwritten_content_item_description.php are structurally identical and contain the same hardcoded German default text for booking and mootimeter. The two differ only in the name of the static method (get_default vs get_overwritten_default) and in the fallback (one returns get_string('modulename_help', ...), the other returns false).

Only overwritten_content_item_description is actually referenced from runtime code (activity_data.php, settings.php); plugin_description is unused outside of its own file.

Risk Assessment

Low risk. Maintenance burden — fixes to one class will silently miss the other.

Context

overwritten_content_item_description::get() is what runtime code uses to fetch the description that is sent to the LLM and shown in admin settings. plugin_description::get() does the same thing with a slightly different fallback but is not invoked anywhere.

classes/local/plugin_description.php:28Source link unavailable — plugin was reviewed from zip without a matching git ref
Identified Code
class plugin_description {
    public static function get(string $pluginname): string {
        $usedefault = get_config('local_activityfilter', "ai_hint_{$pluginname}_use_default");
        if ($usedefault) {
            return self::get_default($pluginname);
        }

        return get_config('local_activityfilter', 'ai_hint_' . $pluginname) ?: '';
    }
Suggested Fix

Delete classes/local/plugin_description.php (it is unreferenced) or, if you want to keep it, fold its get_default semantics into overwritten_content_item_description and replace one class entirely.

code qualityLow
`require` (not `require_once`) used to bootstrap the Composer autoload

stopword_remover.php bootstraps the bundled Composer autoload with a plain require, not require_once:

require(__DIR__ . '/../../vendor/autoload.php');

If stopword_remover.php is ever required twice in the same request (unlikely under Moodle's autoloader, but possible in test setups or future refactors), vendor/autoload.php will execute its top-level code twice. Composer's own autoload_real.php uses static caching, so this currently does not fatal, but the safe and idiomatic form is require_once.

Also note that doing this at file-include time (rather than inside the method that actually uses the library) means every class that triggers autoloading of stopword_remover pays the cost of Composer initialisation even if it never calls compress().

Risk Assessment

Low risk. No runtime impact today because Composer's autoload_real.php is idempotent, but the standard Moodle/PHP pattern is require_once.

Context

stopword_remover is the default i_text_compressor registered via core\di. It uses nlp-tools and voku/stop-words, which are loaded through Composer's PSR-4 autoloader.

classes/activity_searcher/stopword_remover.php:26Source link unavailable — plugin was reviewed from zip without a matching git ref
Identified Code
defined('MOODLE_INTERNAL') || die();

require(__DIR__ . '/../../vendor/autoload.php');
Suggested Fix

Use require_once:

require_once(__DIR__ . '/../../vendor/autoload.php');

Or, better, move the include inside compress() so the autoload only runs when the class is actually invoked.

best practiceLow
Wide-scope `MutationObserver` on `document.body` with `subtree: true`

auto_resize_text_field.js registers a MutationObserver that listens to every DOM mutation under document.body (childList: true, subtree: true) and on every mutation runs document.querySelectorAll('.prompt-search') to find textareas to initialise.

Because the hook before_html_attributes injects this module on every page where the user passes the capability check, the observer is active site-wide for editing teachers — even on pages that will never contain a .prompt-search element. For complex pages (gradebook, reports, dashboards) the constant DOM activity will cause many irrelevant callback invocations.

Risk Assessment

Low risk. Performance / battery concern rather than security. On large pages, observing the entire body subtree can cause measurable CPU usage in the browser.

Context

The observer is initialised from hook_callbacks::before_html_attributes, which fires on every page load for any user who has both local/activityfilter:filter_activities and local/activityfilter:get_max_content_item_occurrence in the page context.

amd/src/auto_resize_text_field.js:58Source link unavailable — plugin was reviewed from zip without a matching git ref
Identified Code
export function init() {
    const autoResizeObserver = new MutationObserver(makeAllResizeable);
    autoResizeObserver.observe(
        document.body,
        {
            childList: true,
            subtree: true
        }
    );
}
Suggested Fix

Scope the observer to the modal element once it is opened, or only set it up after the activity-filter modal is rendered. If you really need a site-wide trigger, observe only the dropdown container that hosts the Add activity button rather than the whole document.

code qualityLow
`ai_dummy_searcher::filter_activities` returns the wrong type per its interface contract

The i_activity_searcher interface declares filter_activities(string $request): array with the docblock specifying activity_ranking[]. The real implementation, ai_searcher, returns an array of activity_ranking objects.

ai_dummy_searcher::filter_activities instead json_decodes dummydata.json with true (associative array mode) and returns the raw decoded structure — an array of plain associative arrays.

This works in practice because the only consumer is the external API, which only inspects array keys, and because PHP does not enforce the docblock type. But it is a contract violation and means any caller that uses activity_ranking's public properties (e.g. $ranking->pluginname) would crash in dummy mode. The fixture also contains entries like "pluginname": "Choice" (capitalised) that will never match a real content_item::get_name() (the AI searcher does a case-sensitive lookup).

Risk Assessment

Low risk. No security impact. The external API still serialises the data correctly. The contract violation will only surface if a future refactor moves consumers off raw array access.

Context

ai_dummy_searcher is wired through hook_callbacks::di_configuration when the admin enables the dummy_mode setting; it is intended as an offline development fixture so contributors do not need a configured AI provider.

classes/activity_searcher/ai_dummy_searcher.php:37Source link unavailable — plugin was reviewed from zip without a matching git ref
Identified Code
public function filter_activities(string $request): array {
    $response = file_get_contents(__DIR__ . '/dummydata.json');
    $decoded = json_decode($response, true);
    if ($decoded === null) {
        throw new Exception("Json encode error: " . json_last_error_msg());
    }
    return $decoded;
}
Suggested Fix

Map the decoded data into activity_ranking objects via activity_ranking::from_stdclass() so the dummy searcher honours the interface contract:

$decoded = json_decode($response, false);
return array_map(
    fn($obj) => activity_ranking::from_stdclass($obj),
    $decoded
);

Also fix the "Choice" capitalisation in dummydata.json so dummy mode produces a representative result.

securityLow
`get_max_content_item_occurrence` returns site-wide aggregate data via a course-scoped capability
Exploitable by:
teachereditingteacher

get_max_content_item_occurrence performs a SELECT COUNT(*) ... GROUP BY m.name ORDER BY COUNT(*) DESC over the entire course_modules and modules tables and returns the largest count. The capability local/activityfilter:get_max_content_item_occurrence is defined at CONTEXT_COURSE, so authorisation is checked against a single course — but the data returned is a site-wide aggregate over every course on the platform.

In other words, any teacher in any course can learn the site-wide maximum count of any module type. The data is not particularly sensitive (it leaks only that, e.g., "the most-used activity type on this site has 4,213 instances"), but the scope mismatch between capability context and data scope is worth tightening.

Risk Assessment

Low risk. The leaked data is an aggregate count of installed modules — administratively visible information that does not identify any user or course. The mismatch is more of a design smell than a real disclosure issue.

Context

The value is used in content_item_ranking.js#getOccurranceString to bucket each suggested activity into very_rare / rare / moderately / often / very_frequent for display purposes — it is a normalisation factor for star ratings.

Proof of Concept

As an editing teacher in any course, call the AJAX local_activityfilter_get_max_content_item_occurrence with the course id. The returned integer reflects activity counts across every course on the site, not just the one whose context was validated.

classes/external/get_max_content_item_occurrence.php:51Source link unavailable — plugin was reviewed from zip without a matching git ref
Identified Code
$ctx = context_course::instance($params['courseid']);
self::validate_context($ctx);
require_capability('local/activityfilter:get_max_content_item_occurrence', $ctx);

$db = di::get(moodle_database::class);
$record = $db->get_record_sql(
    'SELECT COUNT(*) AS count
        FROM {course_modules} cm
        JOIN {modules} m ON m.id = cm.module
        GROUP BY m.name
        ORDER BY COUNT(*) DESC',
    strictness: IGNORE_MULTIPLE
);
Suggested Fix

Either scope the query to the current course (WHERE cm.course = :courseid) so the capability context matches the data context, or define the capability at CONTEXT_SYSTEM and document that it returns site-wide aggregates. The first option is the more conservative choice.

code qualityLow
Inefficient `ORDER BY COUNT(*) DESC` + `IGNORE_MULTIPLE` to pick a maximum

get_max_content_item_occurrence uses get_record_sql(..., strictness: IGNORE_MULTIPLE) to fetch the largest count from a GROUP BY query. This pattern works but is wasteful — the database has to compute counts for every module type, sort the entire result set, and then PHP discards everything except the first row.

A single-row query is both clearer and cheaper:

Risk Assessment

Low risk. Performance only — the data set is small even on large sites, so the inefficiency is noticeable but not crippling.

Context

The web service is invoked every time the activity-filter modal renders a result list. On large Moodle installs with many course modules, the current pattern materialises one row per distinct module type per call.

classes/external/get_max_content_item_occurrence.php:51Source link unavailable — plugin was reviewed from zip without a matching git ref
Identified Code
$record = $db->get_record_sql(
    'SELECT COUNT(*) AS count
        FROM {course_modules} cm
        JOIN {modules} m ON m.id = cm.module
        GROUP BY m.name
        ORDER BY COUNT(*) DESC',
    strictness: IGNORE_MULTIPLE
);
Suggested Fix

Either wrap the grouped query in a SELECT MAX(...) outer query, or pass a $limitfrom / $limitnum to keep the row set bounded:

$record = $db->get_record_sql(
    'SELECT MAX(c) AS count FROM (
        SELECT COUNT(*) AS c
          FROM {course_modules} cm
          JOIN {modules} m ON m.id = cm.module
         GROUP BY m.name
    ) sub'
);
return $record ? (int) $record->count : 0;
code qualityLow
Fragile regex / `str_replace` post-processing of JSON-encoded prompt data

ai_searcher::prepare_prompt builds the description payload by json_encode-ing the activity data and then mutating the resulting JSON string with three text-level transforms:

  1. Strip every <...> substring with preg_replace('/<[^>]*>/', '', ...) to remove HTML tags from help text.
  2. Drop the literal \/innen substring (a German gender-suffix) with str_replace.
  3. Re-substitute \/ back to / (because json_encode was called without JSON_UNESCAPED_SLASHES).

Operating on the encoded JSON instead of the source data is fragile: a help string containing a < followed later by a > would collapse the intervening text (potentially across JSON structural characters), and the slash dance with str_replace makes the code hard to reason about. The current data sources are admin-controlled, so this is not an active security risk, but it is a clear correctness footgun.

Risk Assessment

Low risk. All inputs to the regex are admin- or core-controlled, so there is no path for a low-privileged user to corrupt the JSON. The concern is correctness and readability, not security.

Context

The cleaned JSON is concatenated with the system prompt (admin setting) and the (stopword-filtered) user prompt and sent to the AI provider.

classes/activity_searcher/ai_searcher.php:86Source link unavailable — plugin was reviewed from zip without a matching git ref
Identified Code
public function prepare_prompt(string $userrequest, array $activitydata): generate_text {
    global $USER;
    $plugindescription = json_encode($activitydata, JSON_UNESCAPED_UNICODE);
    $plugindescription = preg_replace('/<[^>]*>/', '', $plugindescription);
    $plugindescription = str_replace("\/innen", "", $plugindescription);
    $plugindescription = str_replace("\/", "/", $plugindescription);
    $plugindescription = str_replace('},{', "\n", $plugindescription);
Suggested Fix

Clean the input data before encoding, and use the right encode flags:

$cleaned = array_map(function ($item) {
    $entry = $item->jsonSerialize();
    $entry['help'] = strip_tags($entry['help']);
    $entry['help'] = str_replace('/innen', '', $entry['help']);
    return $entry;
}, $activitydata);

$plugindescription = json_encode($cleaned, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

This removes the need to substitute slashes back and avoids any risk of the regex eating real JSON structure.

Third-Party Libraries (2)
LibraryVersionLicenseDeclared
nlp-tools
Provides the `PennTreeBankTokenizer`, `TokensDocument` and the `NlpTools\Utils\StopWords` transformation used by `stopword_remover` to tokenise the user's prompt and remove stop-words before sending it to the AI.
0.1.3WTFPL
voku/stop-words
Supplies the English and German stop-word lists that `stopword_remover` feeds into the NlpTools `StopWords` transformation.
2.0.1MIT
Additional AI Notes

The before_html_attributes hook (classes/local/hook_callbacks.php line 92) does the right things in the right order: it short-circuits when no AI provider is available and dummy mode is off, then checks both plugin capabilities against $PAGE->context before injecting the JS. Note that global $PAGE; is declared twice (lines 100 and 110) — harmless but worth tidying.

ai_searcher::send_request constructs the generate_text action with context_system::instance()->id (line 101). Using the originating course context would give better attribution in the AI usage report and policy register — consider passing the course id down from the external API.

activity_summarizer::get_activities() is declared as array|null on the interface (i_activity_summarizer.php line 39) but the implementation always returns an array (content_item_manager::get_all() returns array). Tighten the interface to array to match.

Settings are emitted per content item with names like local_activityfilter/ai_hint_{$itemid} and local_activityfilter/ai_hint_{$itemid}_use_default (settings.php lines 82-103). There is no db/uninstall.php, so these dynamic settings will be left behind in config_plugins when the plugin is uninstalled. Not strictly required, but cleaner to add.

This review was generated by an AI system and may contain inaccuracies. Findings should be verified by a human reviewer before acting on them.