Skip to content
Open
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
5 changes: 5 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@
'sphinx.ext.napoleon'
]

# forum.toggles reads its waffle flags from edx-platform, which is the host
# application and is not installed when the docs are built. Mocking it lets
# autodoc document the module instead of failing to import it.
autodoc_mock_imports = ['openedx']

# A list of warning types to suppress arbitrary warning messages.
suppress_warnings = [
'image.nonlocal_uri',
Expand Down
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 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
104 changes: 102 additions & 2 deletions src/forum/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
UserVote,
Subscription,
MongoContent,
ModerationAuditLog,
)


Expand Down Expand Up @@ -55,11 +56,12 @@ class CommentThreadAdmin(admin.ModelAdmin): # type: ignore
"context",
"closed",
"pinned",
"is_spam",
"created_at",
"updated_at",
)
search_fields = ("title", "body", "author__username", "course_id")
list_filter = ("thread_type", "context", "closed", "pinned")
list_filter = ("thread_type", "context", "closed", "pinned", "is_spam")


@admin.register(Comment)
Expand All @@ -74,9 +76,10 @@ class CommentAdmin(admin.ModelAdmin): # type: ignore
"updated_at",
"endorsed",
"anonymous",
"is_spam",
)
search_fields = ("body", "author__username", "comment_thread__title")
list_filter = ("endorsed", "anonymous")
list_filter = ("endorsed", "anonymous", "is_spam")


@admin.register(EditHistory)
Expand Down Expand Up @@ -152,3 +155,100 @@ class MongoContentAdmin(admin.ModelAdmin): # type: ignore

list_display = ("mongo_id", "content_object_id", "content_type")
search_fields = ("mongo_id",)


@admin.register(ModerationAuditLog)
class ModerationAuditLogAdmin(admin.ModelAdmin): # type: ignore
"""Admin interface for ModerationAuditLog model."""

list_display = (
"timestamp",
"classification",
"actions_taken",
"body_preview",
"original_author",
"moderator_override",
"confidence_score",
)
list_filter = (
"classification",
"moderator_override",
"timestamp",
)
search_fields = (
"original_author__username",
"moderator__username",
"reasoning",
"override_reason",
"body",
)
readonly_fields = (
"timestamp",
"body",
"classifier_output",
"reasoning",
"classification",
"actions_taken",
"confidence_score",
"original_author",
)
fieldsets = (
(
"Moderation Decision",
{
"fields": (
"timestamp",
"classification",
"actions_taken",
"confidence_score",
"reasoning",
)
},
),
("Content Information", {"fields": ("body",)}),
("Author Information", {"fields": ("original_author",)}),
(
"Human Override",
{
"fields": (
"moderator_override",
"moderator",
"override_reason",
)
},
),
(
"Technical Details",
{
"fields": ("classifier_output",),
"classes": ("collapse",),
},
),
)

def body_preview(self, obj): # type: ignore
"""Return a truncated preview of the body for list display."""
if obj.body:
return obj.body[:100] + "..." if len(obj.body) > 100 else obj.body
return "-"

body_preview.short_description = "Body Preview" # type: ignore

# pylint: disable=unused-argument
def has_add_permission(self, request): # type: ignore[no-untyped-def]
"""Disable adding audit logs manually."""
return False

# pylint: disable=unused-argument
def has_delete_permission(self, request, obj=None): # type: ignore[no-untyped-def]
"""Disable deleting audit logs to maintain integrity."""
return False

def get_queryset(self, request): # type: ignore
"""Optimize queryset with related objects."""
return (
super()
.get_queryset(request)
.select_related("original_author", "moderator")
.order_by("-timestamp")
)
8 changes: 8 additions & 0 deletions src/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.
"""
14 changes: 14 additions & 0 deletions src/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"]
Loading
Loading