forked from openedx/forum
-
Notifications
You must be signed in to change notification settings - Fork 1
feat: make AI Moderation provider-agnostic #64
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| Configure AI moderation | ||
| ####################### | ||
|
|
||
| Forum can classify new threads and comments as spam and flag them, using an AI | ||
| provider of your choosing. Forum ships the interface; you supply a backend that | ||
| wraps your provider. | ||
|
|
||
|
|
||
| 1. Write a backend | ||
| ****************** | ||
|
|
||
| Subclass ``HTTPModerationBackend`` and describe three things: how to | ||
| authenticate, what the request body looks like, and where the verdict sits in | ||
| the response. | ||
|
|
||
| .. code-block:: python | ||
|
|
||
| from django.conf import settings | ||
|
|
||
| from forum.ai_moderation.backends import HTTPModerationBackend | ||
|
|
||
|
|
||
| class MyProviderBackend(HTTPModerationBackend): | ||
| def classify(self, content): | ||
| headers = { | ||
| "content-type": "application/json", | ||
| "Authorization": f"Bearer {settings.MY_PROVIDER_API_KEY}", | ||
| } | ||
| payload = { | ||
| "model": settings.MY_PROVIDER_MODEL, | ||
| "messages": [ | ||
| {"role": "system", "content": self.system_message}, | ||
| {"role": "user", "content": content}, | ||
| ], | ||
| } | ||
|
|
||
| response = self.post(payload, headers) | ||
| if response is None: | ||
| return None | ||
|
|
||
| answer = response["choices"][0]["message"]["content"] | ||
| return self.parse_moderation_payload(answer, response) | ||
|
|
||
| ``self.post()`` and ``self.parse_moderation_payload()`` handle the endpoint, | ||
| timeouts, network failures and parsing; ``self.system_message`` is the prompt. | ||
| See ``forum.ai_moderation.backends.base`` for the interface and what | ||
| ``classify()`` must return. | ||
|
|
||
| If your provider is not a JSON HTTP API, subclass ``BaseModerationBackend`` and | ||
| implement ``classify()`` however you like. | ||
|
|
||
| Put the class anywhere the LMS can import. | ||
|
|
||
|
|
||
| 2. Configure it | ||
| *************** | ||
|
|
||
| .. code-block:: python | ||
|
|
||
| AI_MODERATION_BACKEND = "myproject.moderation.MyProviderBackend" | ||
| AI_MODERATION_API_URL = "https://api.myprovider.example.com/v1/chat/completions" | ||
| AI_MODERATION_USER_ID = 42 | ||
|
|
||
| # Read by your backend, named however you like. | ||
| MY_PROVIDER_API_KEY = "..." | ||
| MY_PROVIDER_MODEL = "..." | ||
|
|
||
| All three forum settings are required and have no defaults. | ||
| ``AI_MODERATION_USER_ID`` is the user that flagging and deletion are attributed | ||
| to; no user is created for you. | ||
|
|
||
| Set them however your deployment sets Django settings -- with Tutor, a plugin | ||
| patching ``openedx-lms-production-settings``. Forum declares these settings in | ||
| its own plugin settings, so there is nothing to add to edx-platform. | ||
|
|
||
| Optional: | ||
|
|
||
| ``AI_MODERATION_SYSTEM_MESSAGE`` | ||
| Your own prompt. Defaults to forum's, which asks for the JSON that | ||
| ``parse_moderation_payload()`` expects. | ||
|
|
||
| ``AI_MODERATION_CONNECTION_TIMEOUT``, ``AI_MODERATION_READ_TIMEOUT`` | ||
| Default to 1.0 and 30 seconds. Moderation runs inline with posting, so keep | ||
| them low. | ||
|
|
||
| ``AI_MODERATION_FLAGGED_CACHE_TTL``, ``AI_MODERATION_FLAGGED_CACHE_PREFIX`` | ||
| Spam verdicts are cached by content hash for 24 hours, so an identical | ||
| repost costs no second API call. Clean verdicts are never cached. | ||
|
|
||
|
|
||
| 3. Turn it on | ||
| ************* | ||
|
|
||
| Two course waffle flags, both off by default: | ||
|
|
||
| ``discussions.enable_ai_moderation`` | ||
| Classify new threads and comments, and flag what comes back spam. | ||
|
|
||
| ``discussions.enable_ai_auto_delete_spam`` | ||
| Also soft delete what was flagged. Has no effect on its own. | ||
|
|
||
| Enable them site-wide in Django admin under ``waffle/flag``, or per course with | ||
| a "Waffle flag course override" at | ||
| ``/admin/waffle_utils/waffleflagcourseoverridemodel/``. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,7 @@ | ||
| How-tos | ||
| ####### | ||
|
|
||
| .. toctree:: | ||
| :maxdepth: 1 | ||
|
|
||
| configure_ai_moderation |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,4 +2,4 @@ | |
| Openedx forum app. | ||
| """ | ||
|
|
||
| __version__ = "0.7.1" | ||
| __version__ = "0.7.2" | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| """ | ||
| AI moderation for forum content. | ||
|
|
||
| Classifies each new thread and comment with the configured AI provider, flags | ||
| what comes back spam, and soft deletes it when auto-delete is enabled. Both | ||
| steps are gated on course waffle flags, and every spam verdict is recorded on a | ||
| moderation audit log. | ||
| """ | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| """ | ||
| AI moderation backends. | ||
|
|
||
| Each backend adapts one AI provider to the common moderation interface. The | ||
| backend in use is chosen with the ``AI_MODERATION_BACKEND`` setting, so nothing | ||
| outside this package needs to know which provider is answering. | ||
| """ | ||
|
|
||
| from forum.ai_moderation.backends.base import ( | ||
| BaseModerationBackend, | ||
| HTTPModerationBackend, | ||
| ) | ||
|
|
||
| __all__ = ["BaseModerationBackend", "HTTPModerationBackend"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,188 @@ | ||
| """ | ||
| Provider-agnostic interface for AI moderation backends. | ||
|
|
||
| A moderation backend is the only part of AI moderation that knows how to talk to | ||
| a particular AI provider. It takes a piece of forum content and returns the | ||
| common moderation result described by :meth:`BaseModerationBackend.classify`; | ||
| everything downstream of that -- caching, flagging, deletion, audit logging -- | ||
| is provider independent and lives in :mod:`forum.ai_moderation.service`. | ||
| """ | ||
|
|
||
| import json | ||
| import logging | ||
| from typing import Any, Dict, Optional, Tuple | ||
|
|
||
| import requests | ||
| from django.conf import settings | ||
|
|
||
| from forum.ai_moderation.defaults import ( | ||
| DEFAULT_CONNECTION_TIMEOUT, | ||
| DEFAULT_READ_TIMEOUT, | ||
| DEFAULT_REASONING, | ||
| DEFAULT_SYSTEM_MESSAGE, | ||
| ) | ||
|
|
||
| log = logging.getLogger(__name__) | ||
|
|
||
| CLASSIFICATION_SPAM = "spam_or_scam" | ||
| CLASSIFICATION_NOT_SPAM = "not_spam" | ||
|
|
||
| # Classifications that count as spam. "spam" is accepted alongside the | ||
| # documented "spam_or_scam" because prompts in the wild return either. | ||
| SPAM_CLASSIFICATIONS = ("spam", CLASSIFICATION_SPAM) | ||
|
|
||
|
|
||
| class BaseModerationBackend: | ||
| """ | ||
| Interface implemented by every AI moderation backend. | ||
| """ | ||
|
|
||
| def classify(self, content: str) -> Optional[Dict[str, Any]]: | ||
| """ | ||
| Classify a piece of forum content. | ||
|
|
||
| Args: | ||
| content: The text content to classify. | ||
|
|
||
| Returns: | ||
| A moderation result:: | ||
|
|
||
| { | ||
| "classification": "spam_or_scam" | "not_spam", | ||
| "reasoning": "...", | ||
| "confidence_score": float | None, | ||
| "full_api_response": <raw provider response, for auditing>, | ||
| } | ||
|
|
||
| or None if the provider could not be reached or answered with | ||
| something that could not be understood. Returning None must never | ||
| raise: a failing classifier degrades moderation, it does not break | ||
| posting. | ||
| """ | ||
| raise NotImplementedError | ||
|
|
||
|
|
||
| class HTTPModerationBackend(BaseModerationBackend): # pylint: disable=abstract-method | ||
| """ | ||
| Base class for backends that call an HTTP moderation API. | ||
|
|
||
| It owns the concerns that are the same whichever provider is in use -- | ||
| reading the endpoint, prompt and timeouts from Django settings, POSTing | ||
| JSON, and turning the classifier's JSON payload into the common moderation | ||
| result. Subclasses only describe the provider's request and response shape. | ||
| """ | ||
|
|
||
| @property | ||
| def api_url(self) -> Optional[str]: | ||
| """Endpoint the classifier is served from.""" | ||
| return getattr(settings, "AI_MODERATION_API_URL", None) | ||
|
|
||
| @property | ||
| def system_message(self) -> str: | ||
| """Prompt describing the classification task and its output format.""" | ||
| return ( | ||
| getattr(settings, "AI_MODERATION_SYSTEM_MESSAGE", None) | ||
| or DEFAULT_SYSTEM_MESSAGE | ||
| ) | ||
|
|
||
| @property | ||
| def timeout(self) -> Tuple[float, float]: | ||
| """Connection and read timeouts, in seconds.""" | ||
| return ( | ||
| getattr( | ||
| settings, "AI_MODERATION_CONNECTION_TIMEOUT", DEFAULT_CONNECTION_TIMEOUT | ||
| ), | ||
| getattr(settings, "AI_MODERATION_READ_TIMEOUT", DEFAULT_READ_TIMEOUT), | ||
| ) | ||
|
|
||
| def post(self, payload: Dict[str, Any], headers: Dict[str, str]) -> Optional[Any]: | ||
| """ | ||
| POST a JSON payload to the configured endpoint and decode the response. | ||
|
|
||
| Returns the decoded JSON body, or None if the request failed. | ||
| """ | ||
| if not self.api_url: | ||
| log.error("AI_MODERATION_API_URL setting is not configured") | ||
| return None | ||
|
|
||
| try: | ||
| response = requests.post( | ||
| self.api_url, | ||
| headers=headers, | ||
| json=payload, | ||
| timeout=self.timeout, | ||
| ) | ||
| response.raise_for_status() | ||
| return response.json() | ||
| except ( | ||
| requests.RequestException, | ||
| requests.Timeout, | ||
| requests.ConnectionError, | ||
| ) as e: | ||
| log.error(f"AI moderation API request failed: {e}") | ||
| return None | ||
| except ValueError as e: | ||
| log.error(f"AI moderation API returned a non-JSON response: {e}") | ||
| return None | ||
|
|
||
| def parse_moderation_payload( | ||
| self, raw_content: Any, full_api_response: Any | ||
| ) -> Optional[Dict[str, Any]]: | ||
| """ | ||
| Turn the JSON document produced by the classifier into a moderation result. | ||
|
|
||
| Args: | ||
| raw_content: The classifier's answer, as a JSON string. Models | ||
| routinely wrap it in a Markdown code fence, which is stripped. | ||
| full_api_response: The provider's whole response, kept for auditing. | ||
|
|
||
| Returns: | ||
| The common moderation result, or None if it could not be parsed. | ||
| """ | ||
| if not isinstance(raw_content, str) or not raw_content.strip(): | ||
| log.error("AI moderation response did not contain any content") | ||
| return None | ||
|
|
||
| try: | ||
| parsed = json.loads(strip_code_fence(raw_content)) | ||
| except json.JSONDecodeError as e: | ||
| log.error(f"Failed to parse AI moderation response JSON: {e}") | ||
| return None | ||
|
|
||
| if not isinstance(parsed, dict): | ||
| log.error( | ||
| f"Expected a JSON object from the AI moderation API, got {type(parsed)}" | ||
| ) | ||
| return None | ||
|
|
||
| return normalize_moderation_result(parsed, full_api_response) | ||
|
|
||
|
|
||
| def normalize_moderation_result( | ||
| parsed: Dict[str, Any], full_api_response: Any | ||
| ) -> Dict[str, Any]: | ||
| """ | ||
| Fill in the keys the rest of AI moderation relies on. | ||
|
|
||
| Any additional keys the classifier returned are preserved: they end up on | ||
| the audit log, where they are worth having. | ||
| """ | ||
| result = dict(parsed) | ||
| result["classification"] = parsed.get("classification", CLASSIFICATION_NOT_SPAM) | ||
| result["reasoning"] = parsed.get("reasoning", DEFAULT_REASONING) | ||
| result["confidence_score"] = parsed.get("confidence_score") | ||
| result["full_api_response"] = full_api_response | ||
| return result | ||
|
|
||
|
|
||
| def strip_code_fence(text: str) -> str: | ||
| """Remove a surrounding Markdown code fence, if the model added one.""" | ||
| stripped = text.strip() | ||
| if not stripped.startswith("```"): | ||
| return stripped | ||
|
|
||
| # Drop the opening fence, which may carry a language hint such as ```json. | ||
| lines = stripped.splitlines()[1:] | ||
| if lines and lines[-1].strip().startswith("```"): | ||
| lines = lines[:-1] | ||
| return "\n".join(lines).strip() |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One small thing to confirm: since forum.ai_moderation is being moved from a single module to a package structure, please make sure there are no remaining imports using the old path in any other repo or downstream branch.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@santhosh-apphelix-2u I have confirmed there is no remaining imports using the old path in any other repo or downstream branch