AI Activity Filter
local_activityfilter
- #2Dead `use core\test\handler_one` import referencing a non-existent core class
- #3`$plugin->requires` is set to Moodle 4.1 but the code requires Moodle 4.5+
- #4Hardcoded German default descriptions in PHP instead of language strings
- #5`plugin_description` and `overwritten_content_item_description` are near-duplicate classes
- #6`require` (not `require_once`) used to bootstrap the Composer autoload
- #7Wide-scope `MutationObserver` on `document.body` with `subtree: true`
- #8`ai_dummy_searcher::filter_activities` returns the wrong type per its interface contract
- #9`get_max_content_item_occurrence` returns site-wide aggregate data via a course-scoped capability
- #10Inefficient `ORDER BY COUNT(*) DESC` + `IGNORE_MULTIPLE` to pick a maximum
- #11Fragile regex / `str_replace` post-processing of JSON-encoded prompt data
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.
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.
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(), thenrequire_capability()against a course-level capability defined indb/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/$_SESSIONusage 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 unusedExternalMaxentOptimizerclass), and no raw HTTP / filesystem I/O — the onlyfile_get_contentscall reads a bundleddummydata.jsonvia__DIR__. - Mustache templates escape every AI-supplied field (
{{pluginname}},{{hint}},{{reason}},{{occurrences}}). The single unescaped{{{activityicon}}}is fed only from corecontent_item::get_icon(), never from the AI response (the JS incontent_item_ranking.jsand the PHP inai_searcher::convert_json_to_rankingoverwrite it fromactivity_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 callingcore_ai/policy.getPolicyStatuson the frontend (compare withaiplacement_courseassist). This is the only medium finding. - A handful of low-severity code-quality issues: a dead
use core\test\handler_oneimport that refers to a class that does not exist in core, two near-duplicate helper classes (plugin_descriptionandoverwritten_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_replacepass over JSON-encoded prompt data. - Smaller infrastructure issues:
requirerather thanrequire_oncefor the Composer autoload, an inefficientORDER BY COUNT(*) DESC+IGNORE_MULTIPLEpattern inget_max_content_item_occurrence, a site-wideMutationObserverondocument.bodywithsubtree: true, and the fact thatget_max_content_item_occurrencereports 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
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 fetchContentItemsRanking → local_activityfilter_filter_activities → core_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.
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.
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 refpublic 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']);
}
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 refasync 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);
Before calling fetchContentItemsRanking, check Policy.getPolicyStatus and surface the acceptance UI when the user has not yet accepted the site's AI policy.
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.
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.
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 refuse core\di;
use core\test\handler_one;
use core_course\local\entity\content_item;
Delete the line:
use core\test\handler_one;
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_configurationhook (Moodle 4.5) - The
core\hook\output\before_html_attributeshook (Moodle 4.5) core_ai\managerandcore_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.
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.
$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$plugin->component = 'local_activityfilter';
$plugin->release = '2.0.0';
$plugin->version = 2026042000;
$plugin->requires = 2022112800;
$plugin->maturity = MATURITY_STABLE;
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.
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.
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.
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 refpublic 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;
}
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 refpublic 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);
}
Same approach — replace the inline strings with get_string() calls.
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.
Low risk. Maintenance burden — fixes to one class will silently miss the other.
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 refclass 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) ?: '';
}
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.
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().
Low risk. No runtime impact today because Composer's autoload_real.php is idempotent, but the standard Moodle/PHP pattern is require_once.
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 refdefined('MOODLE_INTERNAL') || die();
require(__DIR__ . '/../../vendor/autoload.php');
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.
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.
Low risk. Performance / battery concern rather than security. On large pages, observing the entire body subtree can cause measurable CPU usage in the browser.
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 refexport function init() {
const autoResizeObserver = new MutationObserver(makeAllResizeable);
autoResizeObserver.observe(
document.body,
{
childList: true,
subtree: true
}
);
}
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.
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).
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.
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 refpublic 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;
}
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.
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.
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.
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.
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$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
);
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.
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:
Low risk. Performance only — the data set is small even on large sites, so the inefficiency is noticeable but not crippling.
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$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
);
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;
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:
- Strip every
<...>substring withpreg_replace('/<[^>]*>/', '', ...)to remove HTML tags from help text. - Drop the literal
\/innensubstring (a German gender-suffix) withstr_replace. - Re-substitute
\/back to/(becausejson_encodewas called withoutJSON_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.
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.
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 refpublic 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);
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.
| Library | Version | License | Declared |
|---|---|---|---|
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.3 | WTFPL | ✓ |
voku/stop-words Supplies the English and German stop-word lists that `stopword_remover` feeds into the NlpTools `StopWords` transformation. | 2.0.1 | MIT | ✓ |
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.