Skip to content

Commit

Permalink
apps/ai_reports: connect with external API
Browse files Browse the repository at this point in the history
- add a celery task which sends a comment to the XAI server and saves
  the response as AiReport
- connect comment post_save signal with celery task
- rename category to label to be in line with the XAI response
- change AiReport fields to JSONField for now
- add tests
- **BREAKING CHANGE** Reset migrations for the ai_reports app (see
  changelog)
  • Loading branch information
goapunk committed Jun 3, 2024
1 parent c227b42 commit e533682
Show file tree
Hide file tree
Showing 17 changed files with 188 additions and 35 deletions.
1 change: 1 addition & 0 deletions adhocracy-plus/config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -547,3 +547,4 @@
CELERY_RESULT_BACKEND = "redis://localhost:6379"
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
CELERY_RESULT_EXTENDED = True
XAI_API_URL = ""
8 changes: 4 additions & 4 deletions apps/ai_reports/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,26 @@ class AiReportAdmin(admin.ModelAdmin):
model = AiReport
list_display = (
"__str__",
"category",
"label",
"confidence",
"is_pending",
"comment",
)
list_filter = (
"is_pending",
"category",
"label",
"confidence",
"comment",
)
search_fields = (
"category",
"label",
"explanation",
"confidence",
"is_pending",
)
fields = (
"is_pending",
"category",
"label",
"explanation",
"confidence",
"comment",
Expand Down
3 changes: 3 additions & 0 deletions apps/ai_reports/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@

class AiReportsConfig(AppConfig):
name = "apps.ai_reports"

def ready(self):
import apps.ai_reports.signals # noqa:F401
9 changes: 5 additions & 4 deletions apps/ai_reports/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Generated by Django 3.2.19 on 2023-07-27 15:31
# Generated by Django 3.2.19 on 2024-05-30 14:30

from django.db import migrations, models
import django.db.models.deletion
Expand Down Expand Up @@ -40,9 +40,10 @@ class Migration(migrations.Migration):
),
),
("is_pending", models.BooleanField(default=True)),
("category", models.CharField(max_length=50)),
("explanation", models.TextField()),
("confidence", models.FloatField(default=0)),
("label", models.JSONField()),
("explanation", models.JSONField()),
("confidence", models.JSONField()),
("show_in_discussion", models.BooleanField(default=True)),
(
"comment",
models.OneToOneField(
Expand Down
17 changes: 0 additions & 17 deletions apps/ai_reports/migrations/0002_aireport_show_in_discussion.py

This file was deleted.

6 changes: 3 additions & 3 deletions apps/ai_reports/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@

class AiReport(TimeStampedModel):
is_pending = models.BooleanField(default=True)
category = models.CharField(max_length=50)
explanation = models.TextField()
confidence = models.FloatField(default=0)
label = models.JSONField()
explanation = models.JSONField()
confidence = models.JSONField()
show_in_discussion = models.BooleanField(default=True)
comment = models.OneToOneField(
Comment,
Expand Down
10 changes: 9 additions & 1 deletion apps/ai_reports/serializers.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
import json

from rest_framework import serializers

from apps.ai_reports.models import AiReport


class AiReportSerializer(serializers.ModelSerializer):
explanation = serializers.SerializerMethodField()

class Meta:
model = AiReport
fields = (
"category",
"label",
"confidence",
"explanation",
"is_pending",
"comment",
"show_in_discussion",
)

# FIXME: remove once frontend knows what to do with this
def get_explanation(self, ai_report: AiReport) -> str:
return json.dumps(ai_report.explanation)
20 changes: 20 additions & 0 deletions apps/ai_reports/signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import httpx
from django.conf import settings
from django.db.models import signals
from django.dispatch import receiver

from adhocracy4.comments.models import Comment
from apps.ai_reports.tasks import get_classification_for_comment

client = httpx.Client()


@receiver(signals.post_save, sender=Comment)
def get_ai_classification(sender, instance, created, update_fields, **kwargs):
if getattr(settings, "XAI_API_URL"):
comment_text_changed = getattr(instance, "_former_comment") != getattr(
instance, "comment"
)
if created or comment_text_changed:
# FIXME: use delay_on_commit() once updated to celery 5.x
get_classification_for_comment.delay(instance.pk)
58 changes: 58 additions & 0 deletions apps/ai_reports/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import ast

import backoff
import httpx
from celery import shared_task
from django.conf import settings

from adhocracy4.comments.models import Comment
from apps import logger
from apps.ai_reports.models import AiReport

client = httpx.Client()


@shared_task
def get_classification_for_comment(comment_pk: int) -> None:
try:
comment = Comment.objects.get(pk=comment_pk)
response = call_ai_api(comment=comment.comment)
if response.status_code == 200:
extract_and_save_ai_classifications(comment=comment, report=response.json())
else:
logger.error("Error: XAI server returned %s", response.status_code)
except httpx.HTTPError as e:
logger.error("Error connecting to %s: %s", settings.AI_API_URL, str(e))


def skip_retry(e: Exception) -> bool:
if isinstance(e, httpx.HTTPStatusError):
return 400 <= e.response.status_code < 500
return False


@backoff.on_exception(
backoff.expo, httpx.HTTPError, max_tries=4, factor=2, giveup=skip_retry
)
def call_ai_api(comment: str) -> httpx.Response:
response = client.post(
settings.XAI_API_URL,
json={"comment": comment},
headers={"Accept": "application/json", "Content-Type": "application/json"},
timeout=25.0,
)
response.raise_for_status()
return response


def extract_and_save_ai_classifications(comment: Comment, report: dict) -> None:
# FIXME: the data returned from the api is not actually valid json, so we need
# to use ast to explicitly convert it. This should be fixed on their side.
confidence = ast.literal_eval((report["confidence"]))
label = ast.literal_eval(report["label"])
explanation = ast.literal_eval(report["explanation"])

ai_report = AiReport(
comment=comment, confidence=confidence, label=label, explanation=explanation
)
ai_report.save()
17 changes: 17 additions & 0 deletions changelog/8164.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
### Added

- add celery task which sends a comment to the xai and stores the response as
AiReport
- add comment post_save signal which connects comment creation / editing with
the xai celery task
- added tests
- add tests
- add pytest-mock package to dev requirements
- added backup and httpx to fork requirements

### Changed

- rename category to label to be in line with the XAI response
- change AiReport fields to JSONField for now
- **BREAKING CHANGE** Reset migrations for the ai_reports app as there's no
automatic way to convert the `confidence` field to JSONField
1 change: 1 addition & 0 deletions requirements/dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ pytest==7.3.2
pytest-cov==4.1.0
pytest-django==4.5.2
pytest-factoryboy==2.5.1
pytest-mock==3.14.0
2 changes: 2 additions & 0 deletions requirements/fork.txt
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
# requirements needed in this fork, but not a+
backoff==2.2.1
httpx==0.27.0
3 changes: 3 additions & 0 deletions tests/ai_reports/conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from pytest_factoryboy import register

from tests.ideas import factories as idea_factories

from .factories import AiReportFactory

register(AiReportFactory)
register(idea_factories.IdeaFactory)
6 changes: 3 additions & 3 deletions tests/ai_reports/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ class AiReportFactory(factory.django.DjangoModelFactory):
class Meta:
model = AiReport

category = factory.Faker("word")
explanation = factory.Faker("sentence", nb_words=6)
confidence = random.random()
label = factory.List([factory.Faker("word") for _ in range(3)])
explanation = {"xai explanation": [["first word", 0.0123]]}
confidence = [random.random() for _ in range(3)]
comment = factory.SubFactory(CommentFactory)
show_in_discussion = True
51 changes: 51 additions & 0 deletions tests/ai_reports/test_classification_signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import pytest
from django.db.models import signals
from django.test import override_settings

from adhocracy4.comments.models import Comment
from apps.ai_reports.signals import get_ai_classification


@pytest.mark.django_db
def test_comment_not_sent_to_xai_no_url(mocker, idea, comment_factory, caplog):
task_mock = mocker.patch(
"apps.ai_reports.tasks.get_classification_for_comment" ".delay"
)
assert get_ai_classification in signals.post_save._live_receivers(Comment)
comment_factory(content_object=idea, comment="lala")
task_mock.assert_not_called()


@override_settings(XAI_API_URL="https://liqd.net")
@pytest.mark.django_db
def test_comment_sent_to_xai_on_comment_text_change(mocker, idea, comment_factory):
task_mock = mocker.patch(
"apps.ai_reports.tasks.get_classification_for_comment" ".delay"
)

assert get_ai_classification in signals.post_save._live_receivers(Comment)
comment = comment_factory(content_object=idea, comment="lala")
task_mock.assert_called_once_with(comment.pk)
task_mock.reset_mock()

comment.comment = "modified comment"
comment.save()
task_mock.assert_called_once_with(comment.pk)


@override_settings(XAI_API_URL="https://liqd.net")
@pytest.mark.django_db
def test_comment_not_sent_to_xai_without_comment_text_change(
mocker, idea, comment_factory, caplog
):
task_mock = mocker.patch(
"apps.ai_reports.tasks.get_classification_for_comment" ".delay"
)
assert get_ai_classification in signals.post_save._live_receivers(Comment)
comment = comment_factory(content_object=idea, comment="lala")
task_mock.assert_called_once_with(comment.pk)
task_mock.reset_mock()

comment.is_blocked = True
comment.save()
task_mock.assert_not_called()
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import json

import pytest
from django.urls import reverse

Expand Down Expand Up @@ -33,7 +35,9 @@ def test_moderator_and_ai_report_added_in_comment(
)
assert comment.ai_report.explanation == ai_report.explanation

assert ai_report.explanation == response.data["ai_report"]["explanation"]
assert ai_report.explanation == json.loads(
response.data["ai_report"]["explanation"]
)
assert response.status_code == 200


Expand Down
5 changes: 3 additions & 2 deletions tests/userdashboard/test_serializers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
from datetime import timedelta

import pytest
Expand Down Expand Up @@ -63,8 +64,8 @@ def test_num_reports(
comment["ai_report"]
and comment["ai_report"]["comment"] == ai.comment.pk
):
assert comment["ai_report"]["category"] == ai.category
assert comment["ai_report"]["explanation"] == ai.explanation
assert comment["ai_report"]["label"] == ai.label
assert json.loads(comment["ai_report"]["explanation"]) == ai.explanation
assert comment["ai_report"]["confidence"] == ai.confidence
assert comment["ai_report"]["is_pending"] == ai.is_pending

Expand Down

0 comments on commit e533682

Please sign in to comment.