Repository: iai-group/DialogueKit (language: python)

Issue:
Add flask socket server



Discussion / hints:

Fix patch:
diff --git a/dialoguekit/platforms/__init__.py b/dialoguekit/platforms/__init__.py
index 7d5cfbef..1ad45871 100644
--- a/dialoguekit/platforms/__init__.py
+++ b/dialoguekit/platforms/__init__.py
@@ -1,5 +1,6 @@
 """Module level init for the platforms."""
+from dialoguekit.platforms.flask_socket_platform import FlaskSocketPlatform
 from dialoguekit.platforms.platform import Platform
 from dialoguekit.platforms.terminal_platform import TerminalPlatform
 
-__all__ = ["Platform", "TerminalPlatform"]
+__all__ = ["Platform", "TerminalPlatform", "FlaskSocketPlatform"]
diff --git a/dialoguekit/platforms/flask_socket_platform.py b/dialoguekit/platforms/flask_socket_platform.py
new file mode 100644
index 00000000..76d4c916
--- /dev/null
+++ b/dialoguekit/platforms/flask_socket_platform.py
@@ -0,0 +1,146 @@
+"""The Platform facilitates displaying of the conversation."""
+from __future__ import annotations
+
+import logging
+from dataclasses import asdict, dataclass
+from typing import TYPE_CHECKING, Type, cast
+
+from flask import Flask, Request, request
+from flask_socketio import Namespace, SocketIO
+
+from dialoguekit.core import AnnotatedUtterance
+from dialoguekit.platforms.platform import Platform
+
+if TYPE_CHECKING:
+    from dialoguekit.core import Utterance
+    from dialoguekit.participant.agent import Agent
+
+
+logger = logging.getLogger(__name__)
+
+
+class SocketIORequest(Request):
+    """A request that contains a sid attribute."""
+
+    sid: str
+
+
+@dataclass
+class Message:
+    text: str
+    intent: str = None
+
+    @classmethod
+    def from_utterance(self, utterance: Utterance) -> Message:
+        """Converts an utterance to a message.
+
+        Args:
+            utterance: An instance of Utterance.
+
+        Returns:
+            An instance of Message.
+        """
+        message = Message(utterance.text)
+        if isinstance(utterance, AnnotatedUtterance):
+            message.intent = str(utterance.intent)
+        return message
+
+
+@dataclass
+class Response:
+    recipient: str
+    message: Message
+
+
+class FlaskSocketPlatform(Platform):
+    def __init__(self, agent_class: Type[Agent]) -> None:
+        """Represents a platform that uses Flask-SocketIO.
+
+        Args:
+            agent_class: The class of the agent.
+        """
+        super().__init__(agent_class)
+        self.app = Flask(__name__)
+        self.socketio = SocketIO(self.app, cors_allowed_origins="*")
+
+    def start(self, host: str = "127.0.0.1", port: str = "5000") -> None:
+        """Starts the platform.
+
+        Args:
+            host: Hostname.
+            port: Port.
+        """
+        self.socketio.on_namespace(ChatNamespace("/", self))
+        self.socketio.run(self.app, host=host, port=port)
+
+    def display_agent_utterance(
+        self, user_id: str, utterance: Utterance
+    ) -> None:
+        """Emits agent utterance to the client.
+
+        Args:
+            user_id: User ID.
+            utterance: An instance of Utterance.
+        """
+        message = Message.from_utterance(utterance)
+        self.socketio.send(
+            asdict(Response(user_id, message)),
+            room=user_id,
+        )
+
+    def display_user_utterance(
+        self, user_id: str, utterance: Utterance
+    ) -> None:
+        """Overrides the method in Platform to avoid raising an error.
+
+        This method is not used in FlaskSocketPlatform.
+
+        Args:
+            user_id: User ID.
+            utterance: An instance of Utterance.
+        """
+        pass
+
+
+class ChatNamespace(Namespace):
+    def __init__(self, namespace: str, platform: FlaskSocketPlatform) -> None:
+        """Represents a namespace.
+
+        Args:
+            namespace: Namespace.
+            platform: An instance of FlaskSocketPlatform.
+        """
+        super().__init__(namespace)
+        self._platform = platform
+
+    def on_connect(self) -> None:
+        """Connects client to platform."""
+        req: SocketIORequest = cast(SocketIORequest, request)
+        self._platform.connect(req.sid)
+        logger.info(f"Client connected; user_id: {req.sid}")
+
+    def on_disconnect(self) -> None:
+        """Disconnects client from server."""
+        req: SocketIORequest = cast(SocketIORequest, request)
+        self._platform.disconnect(req.sid)
+        logger.info(f"Client disconnected; user_id: {req.sid}")
+
+    def on_message(self, data: dict) -> None:
+        """Receives message from client and sends response.
+
+        Args:
+            data: Data received from client.
+        """
+        req: SocketIORequest = cast(SocketIORequest, request)
+        self._platform.message(req.sid, data["message"])
+        logger.info(f"Message received: {data}")
+
+    def on_feedback(self, data: dict) -> None:
+        """Receives feedback from client.
+
+        Args:
+            data: Data received from client.
+        """
+        req: SocketIORequest = cast(SocketIORequest, request)
+        logger.info(f"Feedback received: {data}")
+        self._platform.feedback(req.sid, **data["feedback"])
diff --git a/docs/source/dialogue_orchestration.rst b/docs/source/dialogue_orchestration.rst
index 3bd1c15d..8f1cbd38 100644
--- a/docs/source/dialogue_orchestration.rst
+++ b/docs/source/dialogue_orchestration.rst
@@ -17,5 +17,5 @@ Platform
 :py:mod:`dialoguekit.platforms.platform`
 
 The platform's responsibility is to facilitate the conversation and ensure that the participant can see the agent's utterances and reply to it.
-DialogueKit includes a simple terminal-based platform.
-However, other platforms (e.g., various messaging apps/services, such as Telegram or Facebook Messenger or Flask chat) can be created by inheriting from :py:class:`dialoguekit.platforms.platform.Platform`.
+DialogueKit includes a simple terminal-based platform and a Flask-SocketIO-based platform.
+However, other platforms (e.g., various messaging apps/services, such as Telegram or Facebook Messenger or Flask REST API) can be created by inheriting from :py:class:`dialoguekit.platforms.platform.Platform`.
diff --git a/requirements/requirements.txt b/requirements/requirements.txt
index f2a134f1..f09f8b1c 100644
--- a/requirements/requirements.txt
+++ b/requirements/requirements.txt
@@ -1,4 +1,7 @@
 -r pre_commit.txt
 scikit-learn >= 0.24
 rasa >= 3.0.8
-websockets<11.0
\ No newline at end of file
+flask == 2.2.3
+flask-socketio == 5.3.3
+Werkzeug==2.2.3
+websockets<11.0


Test patch:
diff --git a/tests/platforms/test_flask_socketo_platform.py b/tests/platforms/test_flask_socketo_platform.py
new file mode 100644
index 00000000..23e66eae
--- /dev/null
+++ b/tests/platforms/test_flask_socketo_platform.py
@@ -0,0 +1,125 @@
+"""Test the classes in flask_socket_platform.py."""
+
+from unittest import mock
+
+import pytest
+
+from dialoguekit.core import AnnotatedUtterance, Intent, Utterance
+from dialoguekit.participant import DialogueParticipant
+from dialoguekit.platforms import FlaskSocketPlatform
+from dialoguekit.platforms.flask_socket_platform import ChatNamespace, Message
+from sample_agents import ParrotAgent
+
+
+class TestFlaskSocketPlatform(FlaskSocketPlatform):
+    def __init__(self, *args, **kwargs):
+        """Initialize the platform with mock methods."""
+        super().__init__(*args, **kwargs)
+        self.connect = mock.MagicMock(spec=self.connect)
+        self.disconnect = mock.MagicMock(spec=self.disconnect)
+        self.message = mock.MagicMock(spec=self.message)
+        self.feedback = mock.MagicMock(spec=self.feedback)
+
+
+@pytest.fixture
+def platform():
+    """Create a FlaskSocketPlatform with a ParrotAgent."""
+    platform = TestFlaskSocketPlatform(agent_class=ParrotAgent)
+    platform.app.config["TESTING"] = True
+    yield platform
+
+
+@pytest.fixture
+def socket_client(platform):
+    """Create a test client for socketio."""
+    platform.socketio.on_namespace(ChatNamespace("/", platform))
+    yield platform.socketio.test_client(
+        platform.app,
+        flask_test_client=platform.app.test_client(),
+    )
+
+
+def test_message_from_utterance():
+    """Test that a Message is created from an Utterance."""
+    text = "Hello, world!"
+    utterance = Utterance(text, DialogueParticipant.AGENT)
+
+    message = Message.from_utterance(utterance)
+
+    assert message.text == text
+    assert message.intent is None
+
+
+def test_message_from_annotated_utterance():
+    """Test that a Message is created from an AnnotatedUtterance."""
+    text = "Hello, world!"
+    intent = "greeting"
+    annotated_utterance = AnnotatedUtterance(
+        text, DialogueParticipant.AGENT, intent=Intent(intent)
+    )
+
+    annotated_message = Message.from_utterance(annotated_utterance)
+
+    assert annotated_message.text == text
+    assert annotated_message.intent == intent
+
+
+@mock.patch("flask_socketio.SocketIO.run")
+def test_platform_start(mock_run, platform):
+    """Test that the platform starts the server."""
+    host = "127.0.0.1"
+    port = "5001"
+
+    platform.start(host, port)
+    mock_run.assert_called_once_with(platform.app, host=host, port=port)
+
+
+def test_connection(platform, socket_client):
+    """Test that a connection is established."""
+    assert socket_client.is_connected()
+
+    platform.connect.assert_called_once()
+    platform.disconnect.assert_not_called()
+
+
+def test_disconnection(platform, socket_client):
+    """Test platform disconnects the user when client disconnects."""
+    socket_client.disconnect()
+
+    platform.connect.assert_called_once()
+    platform.disconnect.assert_called_once()
+
+
+def test_receive_message(platform, socket_client):
+    """Test platform receives a message from the client."""
+    socket_client.send({"message": "Hello!"})
+
+    platform.message.assert_called_once_with(mock.ANY, "Hello!")
+
+
+def test_receive_feedback(platform, socket_client):
+    """Test platform receives feedback from the client."""
+    feedback_data = {"utterance_id": 5, "value": 1}
+    socket_client.emit("feedback", {"feedback": feedback_data})
+
+    platform.feedback.assert_called_once_with(mock.ANY, **feedback_data)
+
+    received = socket_client.get_received()
+    assert len(received) == 0
+
+
+@mock.patch("flask_socketio.SocketIO.send")
+def test_display_agent_utterance(send, platform):
+    """Test that the agent utterance is sent to the user."""
+    user_id = "test_user_id"
+    text = "Hello, I'm an agent!"
+    utterance = Utterance(text, DialogueParticipant.AGENT)
+
+    platform.display_agent_utterance(user_id, utterance)
+    send.assert_called_once_with(
+        {
+            "recipient": user_id,
+            "message": {"text": text, "intent": None},
+        },
+        room=user_id,
+    )