Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions docs/how-tos/configure_ai_moderation.rst
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/``.
5 changes: 5 additions & 0 deletions docs/how-tos/index.rst
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
How-tos
#######

.. toctree::
:maxdepth: 1

configure_ai_moderation
2 changes: 1 addition & 1 deletion forum/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
Openedx forum app.
"""

__version__ = "0.7.1"
__version__ = "0.7.2"
8 changes: 8 additions & 0 deletions forum/ai_moderation/__init__.py
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.
"""

Copy link
Copy Markdown

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.

Copy link
Copy Markdown
Author

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

14 changes: 14 additions & 0 deletions forum/ai_moderation/backends/__init__.py
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"]
188 changes: 188 additions & 0 deletions forum/ai_moderation/backends/base.py
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()
Loading
Loading