Skip to content
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

Chat: automatic answers & translations #3051

Merged
merged 1 commit into from
Sep 25, 2024
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
40 changes: 40 additions & 0 deletions docs/src/api-docs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1192,3 +1192,43 @@ it will be passed along in the following format, together with a matching HTTP s
}

The response to ``GET``-ing the endpoint with an ``attachment_id`` is either the (binary) file or an error in the format specified above.

Zammad Webhook
==============

This webhook triggers automatic translations and answers for incoming and outgoing chat messages via Zammad webhooks. The request payload is defined by Zammad, see https://admin-docs.zammad.org/en/latest/manage/webhook/payload.html.

REQUEST
~~~~~~~

.. code:: http

POST /api/v3/webhook/zammad/ HTTP/2

Body:

.. code:: javascript

{
"article": {
"subject": String, // Title of the article that triggered the webhook
"internal": Boolean, // Indicates if the Zammad article is visible to the customer
"body": String, // Article content/message
"created_by" {
"login": String // e-mail address of user that created the article
}
},
"ticket": {
"id": Integer, // ID of the ticket that triggered the webhook
}

RESPONSE
~~~~~~~~

.. code:: javascript

{
"original_message": String, // Copy of the original body
"region": String, // Region
"actions": [ Object ], // list of actions taken
}
1 change: 1 addition & 0 deletions integreat_cms/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@
#: The url patterns of this module (see :doc:`django:topics/http/urls`)
urlpatterns: list[URLPattern] = [
path("api/v3/regions/", include(region_api_urlpatterns)),
path("api/v3/webhook/zammad/", user_chat.zammad_webhook, name="zammad_webhook"),
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please note: /api/v3/webhook/... is a generic new path for webhooks.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

path("wp-json/extensions/v3/sites/", include(region_api_urlpatterns)),
path("api/v3/social/", include(social_media_api_urlpatterns)),
path(
Expand Down
46 changes: 46 additions & 0 deletions integreat_cms/api/v3/chat/chat_bot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""
Wrapper for the Chat Bot / LLM API
"""

import requests
from django.conf import settings

from ....cms.models import Region


class ChatBot:
"""
API Wrapper for the LLM / Chat Bot
"""

def __init__(self, hostname: str = "igchat-inference.tuerantuer.org"):
self.hostname = hostname

def automatic_answer(self, message: str, region: Region, language_slug: str) -> str:
"""
Get automatic answer to question
"""
url = f"https://{self.hostname}/chatanswers/extract_answer/"
body = {"message": message, "language": language_slug, "region": region.slug}
r = requests.post(url, json=body, timeout=30)
return self.format_message(r.json())

def format_message(self, response: dict) -> str:
"""
Transform JSON into readable message
"""
sources = "".join(
[
f"<li><a href='{settings.WEBAPP_URL}{path}'>{path}</a></li>"
for path in response["sources"]
]
)
return f"{response['answer']}\n<ul>{sources}</ul>"

def automatic_translation(
self, message: str, source_lang_slug: str, target_lang_slug: str
) -> str:
"""
Use LLM to translate message
"""
raise NotImplementedError
91 changes: 89 additions & 2 deletions integreat_cms/api/v3/chat/user_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,23 @@

from __future__ import annotations

import json
import logging
import random
import socket
from typing import TYPE_CHECKING

from django.conf import settings
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from google.cloud import translate_v2 as translate # type: ignore[attr-defined]
from google.oauth2 import service_account

from ....cms.models import ABTester, AttachmentMap, UserChat
from ....cms.models import ABTester, AttachmentMap, Language, Region, UserChat
from ...decorators import json_response
from .chat_bot import ChatBot
from .zammad_api import ZammadChatAPI

if TYPE_CHECKING:
Expand Down Expand Up @@ -99,6 +106,18 @@ def get_messages(
return response_or_error(client.get_messages(user_chat))


def translate_message(message: str, language_slug: str) -> str:
"""
Translate a string with Google Translate
"""
credentials = service_account.Credentials.from_service_account_file(
settings.GOOGLE_APPLICATION_CREDENTIALS
)
translate_client = translate.Client(credentials=credentials)
result = translate_client.translate(message, target_language=language_slug)
svenseeberg marked this conversation as resolved.
Show resolved Hide resolved
return result["translatedText"]


def send_message(
request: HttpRequest,
language_slug: str,
Expand All @@ -120,7 +139,12 @@ def send_message(
if request.POST.get("force_new") or not user_chat:
try:
chat_id = client.create_ticket(device_id, language_slug)["id"]
user_chat = UserChat.objects.create(device_id=device_id, zammad_id=chat_id)
user_chat = UserChat.objects.create(
device_id=device_id,
zammad_id=chat_id,
region=request.region,
language=Language.objects.get(slug=language_slug),
)
except KeyError:
logger.warning(
"Failed to create a new chat in %r",
Expand Down Expand Up @@ -210,3 +234,66 @@ def chat(
if request.method == "GET":
return get_messages(request, client, user_chat, device_id)
return send_message(request, language_slug, client, user_chat, device_id)


@csrf_exempt
@json_response
def zammad_webhook(request: HttpRequest) -> JsonResponse:
"""
Receive webhooks from Zammad to update the latest article translation
"""
zammad_url = (
f"https://{socket.getnameinfo((request.META.get('REMOTE_ADDR'), 0), 0)[0]}"
)
region = get_object_or_404(Region, zammad_url=zammad_url)
client = ZammadChatAPI(region)
webhook_message = json.loads(request.body)
message_text = webhook_message["article"]["body"]
zammad_chat = UserChat.objects.get(zammad_id=webhook_message["ticket"]["id"])

actions = []
if webhook_message["article"]["internal"]:
return JsonResponse(
{
"region": region.slug,
"results": "skipped internal message",
}
)
if (
webhook_message["article"]["created_by"]["login"]
== "[email protected]"
):
logger.debug("Automatic answer & question translation")
actions.append("Automatic answer & question translation")
client.send_message(
zammad_chat.zammad_id,
translate_message(message_text, region.default_language.slug),
True,
True,
)
chat_bot = ChatBot()
answer = chat_bot.automatic_answer(
message_text, region, zammad_chat.language.slug
)
client.send_message(
zammad_chat.zammad_id,
answer,
False,
True,
)
else:
logger.debug("Automatic answer translation")
actions.append("Automatic answer translation")
client.send_message(
zammad_chat.zammad_id,
translate_message(message_text, zammad_chat.language.slug),
False,
True,
)
return JsonResponse(
{
"original_message": message_text,
"region": region.slug,
"actions": actions,
}
)
24 changes: 18 additions & 6 deletions integreat_cms/api/v3/chat/zammad_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
logger = logging.getLogger(__name__)


AUTO_ANSWER_STRING = "automatically generated message"


# pylint: disable=unused-argument
def _raise_or_return_json(self: Any, response: HttpResponse) -> dict:
"""
Expand Down Expand Up @@ -90,23 +93,26 @@ def _parse_response(self, response: dict | list[dict]) -> dict | list[dict]:
return [self._parse_response(item) for item in response] # type: ignore[misc]

if author := response.get("sender"):
response["user_is_author"] = author == "Customer"
response["user_is_author"] = (
author == "Customer" and response.get("subject") != AUTO_ANSWER_STRING
)
response["automatic_answer"] = response.get("subject") == AUTO_ANSWER_STRING
keys_to_keep = [
"status",
"error",
"id",
"body",
"user_is_author",
"attachments",
"automatic_answer",
]

return {key: response[key] for key in keys_to_keep if key in response}

# pylint: disable=method-hidden
def create_ticket(self, device_id: str, language_slug: str) -> dict:
"""
Create a new ticket (i.e. initialize a new chat conversation) and
automatically subscribe the responsible Zammad users
Create a new ticket (i.e. initialize a new chat conversation)

:param device_id: ID of the user requesting a new chat
:param language_slug: user's language
Expand Down Expand Up @@ -159,16 +165,22 @@ def get_messages(self, chat: UserChat) -> dict[str, dict | list[dict]]:
return {"messages": response}

# pylint: disable=method-hidden
def send_message(self, chat_id: int, message: str) -> dict:
def send_message(
self, chat_id: int, message: str, internal: bool = False, auto: bool = False
) -> dict:
"""
Post a new message to the given ticket
"""
params = {
"ticket_id": chat_id,
"body": message,
"type": "web",
"internal": False,
"sender": "Customer",
"content_type": "text/html",
"internal": internal,
"subject": (
"automatically generated message" if auto else "app user message"
),
"sender": "Customer" if not auto else "Agent",
}
return self._parse_response( # type: ignore[return-value]
self._attempt_call(self.client.ticket_article.create, params=params)
Expand Down
Loading
Loading