Skip to content

Commit

Permalink
feat: add spam detection endpoints: api/spam/get-latest-batch/ and …
Browse files Browse the repository at this point in the history
…`api/spam/update`. A SpamModeration record with status `SCHEDULED_FOR_CHECK` is stored on every Job, Event, Codebase submission. A decoupled external service will query for these objects to check them for spam.
  • Loading branch information
asuworks committed Nov 14, 2024
1 parent 1604bc6 commit 0b8d8db
Show file tree
Hide file tree
Showing 10 changed files with 464 additions and 15 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ SECRETS_DIR=${BUILD_DIR}/secrets
DB_PASSWORD_PATH=${SECRETS_DIR}/db_password
PGPASS_PATH=${SECRETS_DIR}/.pgpass
SECRET_KEY_PATH=${SECRETS_DIR}/django_secret_key
EXT_SECRETS=hcaptcha_secret github_client_secret orcid_client_secret discourse_api_key discourse_sso_secret mail_api_key
EXT_SECRETS=hcaptcha_secret github_client_secret orcid_client_secret discourse_api_key discourse_sso_secret mail_api_key llm_spam_check_api_key
GENERATED_SECRETS=$(DB_PASSWORD_PATH) $(PGPASS_PATH) $(SECRET_KEY_PATH)

ENVREPLACE := deploy/scripts/envreplace
Expand Down
3 changes: 3 additions & 0 deletions base.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ services:
- orcid_client_secret
- hcaptcha_secret
- mail_api_key
- llm_spam_check_api_key
volumes:
- ./deploy/elasticsearch.conf.d:/etc/elasticsearch
- ./docker/shared:/shared
Expand Down Expand Up @@ -101,6 +102,8 @@ secrets:
file: ./build/secrets/mail_api_key
orcid_client_secret:
file: ./build/secrets/orcid_client_secret
llm_spam_check_api_key:
file: ./build/secrets/llm_spam_check_api_key

volumes:
esdata:
Expand Down
36 changes: 23 additions & 13 deletions django/core/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,27 +295,37 @@ def mark_spam(self, request, **kwargs):
return redirect(instance.get_list_url())

def handle_spam_detection(self, serializer: serializers.Serializer):
if "spam_context" in serializer.context:
try:
self._validate_content_object(serializer.instance)
self._record_spam(
serializer.instance, serializer.context["spam_context"]
)
except ValueError as e:
logger.warning("Cannot flag %s as spam: %s", serializer.instance, e)
try:
self._validate_content_object(serializer.instance)
self._record_spam(
serializer.instance,
(
serializer.context["spam_context"]
if "spam_context" in serializer.context
else None
),
)
except ValueError as e:
logger.warning("Cannot flag %s as spam: %s", serializer.instance, e)

def _record_spam(self, instance, spam_context: dict):
def _record_spam(self, instance, spam_context: dict = None):
content_type = ContentType.objects.get_for_model(type(instance))

# SpamModeration updates the content instance on save
spam_moderation, created = SpamModeration.objects.get_or_create(
content_type=content_type,
object_id=instance.id,
defaults={
"status": SpamModeration.Status.UNREVIEWED,
"detection_method": spam_context["detection_method"],
"detection_details": spam_context["detection_details"],
"status": SpamModeration.Status.SCHEDULED_FOR_CHECK,
"detection_method": (
spam_context["detection_method"] if spam_context else ""
),
"detection_details": (
spam_context["detection_details"] if spam_context else ""
),
},
)

if not created:
spam_moderation.status = SpamModeration.Status.UNREVIEWED
spam_moderation.status = SpamModeration.Status.SCHEDULED_FOR_CHECK
spam_moderation.save()
15 changes: 14 additions & 1 deletion django/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ class Status(models.TextChoices):
UNREVIEWED = "unreviewed", _("Unreviewed")
SPAM = "spam", _("Confirmed spam")
NOT_SPAM = "not_spam", _("Confirmed not spam")
SCHEDULED_FOR_CHECK = "scheduled_for_check", _("Scheduled for check by LLM")
SPAM_LIKELY = "spam_likely", _("Marked spam by LLM")
NOT_SPAM_LIKELY = "not_spam_likely", _("Marked as not spam by LLM")

status = models.CharField(
choices=Status.choices,
Expand Down Expand Up @@ -166,6 +169,16 @@ class Status(models.TextChoices):
blank=True,
)

# detection_details is a JSON field
def mark_as_spam_by_llm(self, status: Status, detection_details=None):
logger.info("Marking %s as %s by LLM", self, status)
self.reviewer = None
self.status = status
self.detection_method = "LLM"
if detection_details:
self.detection_details = detection_details
self.save()

def mark_not_spam(self, reviewer: User, detection_details=None):
logger.info("user %s marking %s as not spam", reviewer, self)
self.status = self.Status.NOT_SPAM
Expand All @@ -182,7 +195,7 @@ def update_related_object(self):
related_object = self.content_object
if hasattr(related_object, "is_marked_spam"):
related_object.spam_moderation = self
related_object.is_marked_spam = self.status != self.Status.NOT_SPAM
related_object.is_marked_spam = self.status == self.Status.SPAM
related_object.save()

def __str__(self):
Expand Down
2 changes: 2 additions & 0 deletions django/core/settings/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,8 @@ def is_test(self):
DISCOURSE_API_KEY = read_secret("discourse_api_key", "unconfigured")
DISCOURSE_API_USERNAME = os.getenv("DISCOURSE_API_USERNAME", "unconfigured")

LLM_SPAM_CHECK_API_KEY = read_secret("llm_spam_check_api_key", "unconfigured")

# https://docs.djangoproject.com/en/4.2/ref/settings/#templates
TEMPLATES = [
{
Expand Down
20 changes: 20 additions & 0 deletions django/curator/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed, NotAuthenticated
from django.conf import settings
from rest_framework.permissions import BasePermission


class APIKeyAuthentication(BaseAuthentication):
def authenticate_header(self, request):
return "X-API-Key"

def authenticate(self, request):
api_key = request.META.get("HTTP_X_API_KEY")

if not api_key:
raise AuthenticationFailed("No API key")

if api_key != settings.LLM_SPAM_CHECK_API_KEY:
raise AuthenticationFailed("Invalid API key")

return (None, None)
70 changes: 70 additions & 0 deletions django/curator/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from rest_framework import serializers
from core.models import SpamModeration
from django.contrib.contenttypes.models import ContentType
from rest_framework import serializers
from core.models import Event, Job, SpamModeration
from django.contrib.contenttypes.models import ContentType

from library.models import Codebase


class SpamModerationSerializer(serializers.ModelSerializer):
content_type = serializers.CharField(source="content_type.model")

class Meta:
model = SpamModeration
fields = [
"id",
# "status",
"content_type",
"object_id",
# "date_created",
# "last_modified",
# "notes",
# "detection_method",
# "detection_details",
]


class MinimalJobSerializer(serializers.ModelSerializer):
class Meta:
model = Job
fields = [
"id",
"title",
"summary",
"description",
"external_url",
]


class MinimalEventSerializer(serializers.ModelSerializer):
class Meta:
model = Event
fields = [
"id",
"title",
"summary",
"description",
"external_url",
]


class MinimalCodebaseSerializer(serializers.ModelSerializer):
class Meta:
model = Codebase
fields = [
"id",
"title",
"description",
]


class SpamUpdateSerializer(serializers.Serializer):
object_id = serializers.IntegerField()
is_spam = serializers.BooleanField()
spam_indicators = serializers.ListField(
child=serializers.CharField(), required=False
)
reasoning = serializers.CharField(required=False)
confidence = serializers.FloatField(required=False)
Loading

0 comments on commit 0b8d8db

Please sign in to comment.