Skip to content

Commit fb84d45

Browse files
authored
feat: re-add updates to serde of GeneratedAnswer and ExtractedAnswer (#11983)
1 parent f0156a8 commit fb84d45

5 files changed

Lines changed: 234 additions & 168 deletions

File tree

MIGRATION.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ This document is meant to provide a guide for migrating from Haystack v2.X to v3
88

99
### `GeneratedAnswer` and `ExtractedAnswer` serialization format
1010

11-
**What changed:** `GeneratedAnswer.to_dict()` and `ExtractedAnswer.to_dict()` now return a flat dictionary of the object's fields instead of wrapping them in a `{"type": ..., "init_parameters": {...}}` envelope. `from_dict()` still accepts the old wrapped format, so existing serialized artifacts keep loading.
11+
**What changed:** `GeneratedAnswer.to_dict()` and `ExtractedAnswer.to_dict()` now return a flat dictionary of the object's fields instead of wrapping them in a `{"type": ..., "init_parameters": {...}}` envelope.
1212

1313
**Why:** Aligns these dataclasses with how every other Haystack dataclass (`Document`, `ChatMessage`, etc.) serializes, and removes redundant type metadata from pipeline snapshots and `State` objects.
1414

15-
**How to migrate:** Update any code that reads the serialized output to access fields at the top level instead of under `init_parameters`. See [#11805](https://github.com/deepset-ai/haystack/pull/11805).
15+
**Deserialization is backward compatible:** `from_dict()` accepts both the new flat format and the old wrapped `{"type": ..., "init_parameters": {...}}` format, so existing serialized artifacts (pipeline snapshots, breakpoints, `State` objects) keep loading without any changes on your side.
16+
17+
**How to migrate:** Only code that *reads* the serialized output needs updating: access fields at the top level instead of under `init_parameters`. Code that deserializes with `from_dict()` needs no changes.
1618

1719
Before (v2.x):
1820
```python
@@ -24,6 +26,10 @@ After (v3.0):
2426
```python
2527
serialized = generated_answer.to_dict()
2628
data = serialized["data"]
29+
30+
# Deserialization still accepts both the new and the old format:
31+
GeneratedAnswer.from_dict(serialized) # new flat format
32+
GeneratedAnswer.from_dict(old_wrapped_dict) # old {"type": ..., "init_parameters": {...}} format
2733
```
2834

2935
### Components Moved to External Packages

haystack/dataclasses/answer.py

Lines changed: 52 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from dataclasses import asdict, dataclass, field
66
from typing import Any, Optional, Protocol, runtime_checkable
77

8-
from haystack.core.serialization import default_from_dict, default_to_dict
98
from haystack.dataclasses import ChatMessage, Document
109
from haystack.utils.dataclasses import _warn_on_inplace_mutation
1110

@@ -54,20 +53,16 @@ def to_dict(self) -> dict[str, Any]:
5453
:returns:
5554
Serialized dictionary representation of the object.
5655
"""
57-
document = self.document.to_dict(flatten=False) if self.document is not None else None
58-
document_offset = asdict(self.document_offset) if self.document_offset is not None else None
59-
context_offset = asdict(self.context_offset) if self.context_offset is not None else None
60-
return default_to_dict(
61-
self,
62-
data=self.data,
63-
query=self.query,
64-
document=document,
65-
context=self.context,
66-
score=self.score,
67-
document_offset=document_offset,
68-
context_offset=context_offset,
69-
meta=self.meta,
70-
)
56+
return {
57+
"data": self.data,
58+
"query": self.query,
59+
"document": self.document.to_dict(flatten=False) if self.document is not None else None,
60+
"context": self.context,
61+
"score": self.score,
62+
"document_offset": asdict(self.document_offset) if self.document_offset is not None else None,
63+
"context_offset": asdict(self.context_offset) if self.context_offset is not None else None,
64+
"meta": self.meta,
65+
}
7166

7267
@classmethod
7368
def from_dict(cls, data: dict[str, Any]) -> "ExtractedAnswer":
@@ -79,20 +74,32 @@ def from_dict(cls, data: dict[str, Any]) -> "ExtractedAnswer":
7974
:returns:
8075
Deserialized object.
8176
"""
82-
# Shallow-copy the init parameters so `from_dict` stays side-effect free: the nested
83-
# replacements below otherwise mutate the caller's dict in place, corrupting it for reuse
84-
# (a second deserialization of the same dict would then receive already-parsed objects).
85-
init_params = data.get("init_parameters", {})
86-
new_params = dict(init_params)
87-
if (doc := init_params.get("document")) is not None:
88-
new_params["document"] = Document.from_dict(doc)
89-
90-
if (offset := init_params.get("document_offset")) is not None:
91-
new_params["document_offset"] = ExtractedAnswer.Span(**offset)
92-
93-
if (offset := init_params.get("context_offset")) is not None:
94-
new_params["context_offset"] = ExtractedAnswer.Span(**offset)
95-
return default_from_dict(cls, {**data, "init_parameters": new_params})
77+
# Backward compatibility: the old format wrapped the fields in an `init_parameters` envelope.
78+
if "init_parameters" in data:
79+
data = data["init_parameters"]
80+
81+
document = data.get("document")
82+
if document is not None:
83+
document = Document.from_dict(document)
84+
85+
document_offset = data.get("document_offset")
86+
if document_offset is not None:
87+
document_offset = ExtractedAnswer.Span(**document_offset)
88+
89+
context_offset = data.get("context_offset")
90+
if context_offset is not None:
91+
context_offset = ExtractedAnswer.Span(**context_offset)
92+
93+
return cls(
94+
data=data.get("data"),
95+
query=data["query"],
96+
score=data["score"],
97+
document=document,
98+
context=data.get("context"),
99+
document_offset=document_offset,
100+
context_offset=context_offset,
101+
meta=data.get("meta", {}),
102+
)
96103

97104

98105
@_warn_on_inplace_mutation
@@ -114,17 +121,18 @@ def to_dict(self) -> dict[str, Any]:
114121
:returns:
115122
Serialized dictionary representation of the object.
116123
"""
117-
documents = [doc.to_dict(flatten=False) for doc in self.documents]
118-
119-
# Serialize ChatMessage objects to dicts
124+
# all_messages is either a list of ChatMessage objects or a list of strings
120125
meta = self.meta
121126
all_messages = meta.get("all_messages")
122-
123-
# all_messages is either a list of ChatMessage objects or a list of strings
124127
if all_messages and isinstance(all_messages[0], ChatMessage):
125128
meta = {**meta, "all_messages": [msg.to_dict() for msg in all_messages]}
126129

127-
return default_to_dict(self, data=self.data, query=self.query, documents=documents, meta=meta)
130+
return {
131+
"data": self.data,
132+
"query": self.query,
133+
"documents": [doc.to_dict(flatten=False) for doc in self.documents],
134+
"meta": meta,
135+
}
128136

129137
@classmethod
130138
def from_dict(cls, data: dict[str, Any]) -> "GeneratedAnswer":
@@ -137,20 +145,15 @@ def from_dict(cls, data: dict[str, Any]) -> "GeneratedAnswer":
137145
:returns:
138146
Deserialized object.
139147
"""
140-
# Shallow-copy the init parameters so `from_dict` stays side-effect free: the nested
141-
# replacements below otherwise mutate the caller's dict in place, corrupting it for reuse
142-
# (a second deserialization of the same dict would then receive already-parsed objects).
143-
init_params = data.get("init_parameters", {})
144-
new_params = dict(init_params)
145-
146-
if (documents := init_params.get("documents")) is not None:
147-
new_params["documents"] = [Document.from_dict(d) for d in documents]
148-
149-
# Shallow-copy `meta` before touching `all_messages` so the caller's nested dict is
150-
# left untouched as well.
151-
meta = dict(init_params.get("meta", {}))
148+
# Backward compatibility: the old format wrapped the fields in an `init_parameters` envelope.
149+
if "init_parameters" in data:
150+
data = data["init_parameters"]
151+
152+
documents = [Document.from_dict(d) for d in data.get("documents", [])]
153+
154+
# Copy `meta` before converting `all_messages` so the caller's input dict is left untouched.
155+
meta = dict(data.get("meta", {}))
152156
if (all_messages := meta.get("all_messages")) and isinstance(all_messages[0], dict):
153157
meta["all_messages"] = [ChatMessage.from_dict(m) for m in all_messages]
154-
new_params["meta"] = meta
155158

156-
return default_from_dict(cls, {**data, "init_parameters": new_params})
159+
return cls(data=data["data"], query=data["query"], documents=documents, meta=meta)

releasenotes/notes/fix-dataclasses-deseria-0bb839b5edd3fca6.yaml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,7 @@ upgrade:
66
change: ``to_dict()`` no longer wraps the fields in the ``{"type": "...", "init_parameters":
77
{...}}`` envelope and instead returns the fields at the top level. Any code or stored
88
artifact that reads the serialized output and expects the ``type``/``init_parameters``
9-
keys must be updated to read the fields directly. ``from_dict()`` remains backward
10-
compatible and still accepts the old wrapped format.
9+
keys must be updated to read the fields directly. Deserialization is backward compatible:
10+
``from_dict()`` accepts both the new flat format and the old wrapped
11+
``{"type": "...", "init_parameters": {...}}`` format, so existing pipeline snapshots,
12+
breakpoints, and ``State`` objects keep loading without any changes.

0 commit comments

Comments
 (0)