Repository: deepset-ai/haystack

Issue:
Improve `MultiLabel` serialization
**Is your feature request related to a problem? Please describe.**
This is a follow-up issue to https://github.com/deepset-ai/haystack/issues/2872.
The solution implemented to fix that issue is pretty ugly. Parts were fixed in `Label` instead of addressing the actual problem:
During pydantic value validation answer and document dict are not serialized

**Describe the solution you'd like**
- make sure label args are parsed during `init/post_init` validation
- use proper pydantic ways of defining default values, init-only args, etc via `field` args and `__post__init()` 
- make sure init args are still properly documented so the class can be used as any other non-pydantic class

**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.

**Additional context**
Add any other context or screenshots about the feature request here.



Discussion:

Resolving patch:
diff --git a/haystack/schema.py b/haystack/schema.py
--- a/haystack/schema.py
+++ b/haystack/schema.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 import csv
 import hashlib
+import inspect
 
 from typing import Any, Optional, Dict, List, Union
 
@@ -567,9 +568,6 @@ def __init__(
         self.updated_at = updated_at
         self.query = query
 
-        # TODO: fix MultiLabel serialization without hacking Label
-        # As this is called during pydantic validation when MultiLabel is being serialized,
-        # answer might still be a dict breaking the following no_answer validation code.
         if isinstance(answer, dict):
             answer = Answer.from_dict(answer)
         self.answer = answer
@@ -652,69 +650,55 @@ def is_positive_label(label):
     )
 
 
-@dataclass
 class MultiLabel:
-    labels: List[Label]
-    query: str
-    answers: List[str]
-    document_ids: List[str]
-    contexts: List[str]
-    offsets_in_contexts: List[Dict]
-    offsets_in_documents: List[Dict]
-    drop_negative_labels: InitVar[bool] = False
-    drop_no_answer: InitVar[bool] = False
-
-    def __init__(self, labels: List[Label], drop_negative_labels=False, drop_no_answers=False, **kwargs):
+    def __init__(self, labels: List[Label], drop_negative_labels=False, drop_no_answers=False):
         """
         There are often multiple `Labels` associated with a single query. For example, there can be multiple annotated
         answers for one question or multiple documents contain the information you want for a query.
         This class is "syntactic sugar" that simplifies the work with such a list of related Labels.
-        It stored the original labels in MultiLabel.labels and provides additional aggregated attributes that are
+        It stores the original labels in MultiLabel.labels and provides additional aggregated attributes that are
         automatically created at init time. For example, MultiLabel.no_answer allows you to easily access if any of the
         underlying Labels provided a text answer and therefore demonstrates that there is indeed a possible answer.
 
         :param labels: A list of labels that belong to a similar query and shall be "grouped" together
         :param drop_negative_labels: Whether to drop negative labels from that group (e.g. thumbs down feedback from UI)
         :param drop_no_answers: Whether to drop labels that specify the answer is impossible
-        :param kwargs: All additional attributes are ignored. This is just a workaround to enable smooth `to_dict()`-`from_dict()`-(de)serialization.
         """
         # drop duplicate labels and remove negative labels if needed.
         labels = list(dict.fromkeys(labels))
         if drop_negative_labels:
             labels = [l for l in labels if is_positive_label(l)]
-
         if drop_no_answers:
             labels = [l for l in labels if l.no_answer == False]
 
-        self.labels = labels
-
-        self.query = self._aggregate_labels(key="query", must_be_single_value=True)[0]
-        self.filters = self._aggregate_labels(key="filters", must_be_single_value=True)[0]
+        self._labels = labels
+        self._query = self._aggregate_labels(key="query", must_be_single_value=True)[0]
+        self._filters = self._aggregate_labels(key="filters", must_be_single_value=True)[0]
         self.id = hashlib.md5((self.query + json.dumps(self.filters, sort_keys=True)).encode()).hexdigest()
 
         # Currently no_answer is only true if all labels are "no_answers", we could later introduce a param here to let
         # users decided which aggregation logic they want
-        self.no_answer = False not in [l.no_answer for l in self.labels]
+        self._no_answer = all(l.no_answer for l in self._labels)
 
         # Answer strings and offsets cleaned for no_answers:
         # If there are only no_answers, offsets are empty and answers will be a single empty string
         # which equals the no_answers representation of reader nodes.
-        if self.no_answer:
-            self.answers = [""]
-            self.offsets_in_documents: List[dict] = []
-            self.offsets_in_contexts: List[dict] = []
+        if self._no_answer:
+            self._answers = [""]
+            self._offsets_in_documents: List[dict] = []
+            self._offsets_in_contexts: List[dict] = []
         else:
-            answered = [l.answer for l in self.labels if not l.no_answer and l.answer is not None]
-            self.answers = [answer.answer for answer in answered]
-            self.offsets_in_documents = []
-            self.offsets_in_contexts = []
+            answered = [l.answer for l in self._labels if not l.no_answer and l.answer is not None]
+            self._answers = [answer.answer for answer in answered]
+            self._offsets_in_documents = []
+            self._offsets_in_contexts = []
             for answer in answered:
                 if answer.offsets_in_document is not None:
                     for span in answer.offsets_in_document:
-                        self.offsets_in_documents.append({"start": span.start, "end": span.end})
+                        self._offsets_in_documents.append({"start": span.start, "end": span.end})
                 if answer.offsets_in_context is not None:
                     for span in answer.offsets_in_context:
-                        self.offsets_in_contexts.append({"start": span.start, "end": span.end})
+                        self._offsets_in_contexts.append({"start": span.start, "end": span.end})
 
         # There are two options here to represent document_ids:
         # taking the id from the document of each label or taking the document_id of each label's answer.
@@ -724,9 +708,44 @@ def __init__(self, labels: List[Label], drop_negative_labels=False, drop_no_answ
         # as separate no_answer labels, and thus with document.id but without answer.document_id.
         # If we do not exclude them from document_ids this would be problematic for retriever evaluation as they do not contain the answer.
         # Hence, we exclude them here as well.
+        self._document_ids = [l.document.id for l in self._labels if not l.no_answer]
+        self._contexts = [str(l.document.content) for l in self._labels if not l.no_answer]
+
+    @property
+    def labels(self):
+        return self._labels
+
+    @property
+    def query(self):
+        return self._query
+
+    @property
+    def filters(self):
+        return self._filters
+
+    @property
+    def document_ids(self):
+        return self._document_ids
+
+    @property
+    def contexts(self):
+        return self._contexts
+
+    @property
+    def no_answer(self):
+        return self._no_answer
+
+    @property
+    def answers(self):
+        return self._answers
+
+    @property
+    def offsets_in_documents(self):
+        return self._offsets_in_documents
 
-        self.document_ids = [l.document.id for l in self.labels if not l.no_answer]
-        self.contexts = [str(l.document.content) for l in self.labels if not l.no_answer]
+    @property
+    def offsets_in_contexts(self):
+        return self._offsets_in_contexts
 
     def _aggregate_labels(self, key, must_be_single_value=True) -> List[Any]:
         if any(isinstance(getattr(l, key), dict) for l in self.labels):
@@ -744,20 +763,28 @@ def _aggregate_labels(self, key, must_be_single_value=True) -> List[Any]:
         return unique_values
 
     def to_dict(self):
-        return asdict(self)
+        # convert internal attribute names to property names
+        return {k[1:] if k[0] == "_" else k: v for k, v in vars(self).items()}
 
     @classmethod
     def from_dict(cls, dict: dict):
-        return _pydantic_dataclass_from_dict(dict=dict, pydantic_dataclass_type=cls)
+        # exclude extra arguments
+        return cls(**{k: v for k, v in dict.items() if k in inspect.signature(cls).parameters})
 
     def to_json(self):
-        return json.dumps(self, default=pydantic_encoder)
+        return json.dumps(self.to_dict(), default=pydantic_encoder)
 
     @classmethod
-    def from_json(cls, data):
+    def from_json(cls, data: Union[str, Dict[str, Any]]):
         if type(data) == str:
-            data = json.loads(data)
-        return cls.from_dict(data)
+            dict_data = json.loads(data)
+        else:
+            dict_data = data
+        dict_data["labels"] = [Label.from_dict(l) for l in dict_data["labels"]]
+        return cls.from_dict(dict_data)
+
+    def __eq__(self, other):
+        return isinstance(other, self.__class__) and self.labels == other.labels
 
     def __repr__(self):
         return f"<MultiLabel: {self.to_dict()}>"


Test patch:
diff --git a/test/others/test_schema.py b/test/others/test_schema.py
--- a/test/others/test_schema.py
+++ b/test/others/test_schema.py
@@ -403,6 +403,51 @@ def test_multilabel_with_doc_containing_dataframes():
     assert type(MultiLabel(labels=[label]).contexts[0]) is str
 
 
+def test_multilabel_serialization():
+    label_dict = {
+        "id": "011079cf-c93f-49e6-83bb-42cd850dce12",
+        "query": "When was the final season first shown on TV?",
+        "document": {
+            "content": "\n\n\n\n\nThe eighth and final season of the fantasy drama television series ''Game of Thrones'', produced by HBO, premiered on April 14, 2019, and concluded on May 19, 2019. Unlike the first six seasons, which consisted of ten episodes each, and the seventh season, which consisted of seven episodes, the eighth season consists of only six episodes.\n\nThe final season depicts the culmination of the series' two primary conflicts: the G",
+            "content_type": "text",
+            "id": "9c82c97c9dc8ba6895893a53aafa610f",
+            "meta": {},
+            "score": None,
+            "embedding": None,
+        },
+        "is_correct_answer": True,
+        "is_correct_document": True,
+        "origin": "user-feedback",
+        "answer": {
+            "answer": "April 14",
+            "type": "extractive",
+            "score": None,
+            "context": "\n\n\n\n\nThe eighth and final season of the fantasy drama television series ''Game of Thrones'', produced by HBO, premiered on April 14, 2019, and concluded on May 19, 2019. Unlike the first six seasons, which consisted of ten episodes each, and the seventh season, which consisted of seven episodes, the eighth season consists of only six episodes.\n\nThe final season depicts the culmination of the series' two primary conflicts: the G",
+            "offsets_in_document": [{"start": 124, "end": 132}],
+            "offsets_in_context": None,
+            "document_id": None,
+            "meta": {},
+        },
+        "no_answer": False,
+        "pipeline_id": None,
+        "created_at": "2022-07-22T13:29:33.699781+00:00",
+        "updated_at": "2022-07-22T13:29:33.784895+00:00",
+        "meta": {"answer_id": "374394", "document_id": "604995", "question_id": "345530"},
+        "filters": None,
+    }
+
+    label = Label.from_dict(label_dict)
+    original_multilabel = MultiLabel([label])
+
+    deserialized_multilabel = MultiLabel.from_dict(original_multilabel.to_dict())
+    assert deserialized_multilabel == original_multilabel
+    assert deserialized_multilabel.labels[0] == label
+
+    json_deserialized_multilabel = MultiLabel.from_json(original_multilabel.to_json())
+    assert json_deserialized_multilabel == original_multilabel
+    assert json_deserialized_multilabel.labels[0] == label
+
+
 def test_serialize_speech_document():
     speech_doc = SpeechDocument(
         id=12345,