Repository: deepset-ai/haystack
Pull request: fix: append runtime meta to ChatMessage's extracted meta in AnswerBuilder
### Related Issues

- fixes #8542

### Proposed Changes:

We were ignoring the runtime meta parameter. Now we explicitly merge it.

### How did you test it?

Added a couple of tests that varied how meta was provided (runtime, in ChatMessage, in both, etc).

### Notes for the reviewer


### Checklist

- I have read the [contributors guidelines](https://github.com/deepset-ai/haystack/blob/main/CONTRIBUTING.md) and the [code of conduct](https://github.com/deepset-ai/haystack/blob/main/code_of_conduct.txt)
- I have updated the related issue with new insights and changes
- I added unit tests and updated the docstrings
- I've used one of the [conventional commit types](https://www.conventionalcommits.org/en/v1.0.0/) for my PR title: `fix:`, `feat:`, `build:`, `chore:`, `ci:`, `docs:`, `style:`, `refactor:`, `perf:`, `test:`.
- I documented my code
- I ran [pre-commit hooks](https://github.com/deepset-ai/haystack/blob/main/CONTRIBUTING.md#installation) and fixed any issue


Problem statement:
bug: Runtime meta parameter is ignored in AnswerBuilder
**Describe the bug**
When using an `AnswerBuilder` after a `ChatGenerator` I am also passing meta information to the `AnswerBuilder`. However, when I do this noticed that the meta information I pass is ignored. 

Here is an example piece of code
```python
from haystack import Pipeline
from haystack.dataclasses import ChatRole, ChatMessage
from haystack.components.builders import AnswerBuilder

builder = AnswerBuilder()
pipeline = Pipeline()
pipeline.add_component(name="builder", instance=builder)

# Test the pipeline
result = pipeline.run(
    data={
        "builder": {
            "query": "What's the answer?",
            "replies": [
                ChatMessage(
                    role=ChatRole.ASSISTANT,
                    content="This is the answer from the LLM.",
                    meta={"stuff": "meta data that always comes from the generator"},
                    name=None
                )
            ],
            "meta": [
                {"my_meta": "The meta data I want to add to the answer"}
            ]
        }
    }
)
print(result)
# {'builder':
#     {
#         'answers': [
#             GeneratedAnswer(
#                 data='This is the answer from the LLM.',
#                 query="What's the answer?",
#                 documents=[],
#                 meta={'stuff': 'meta data that always comes from the generator'})  # <-- Should also include meta I passed
#         ]
# }}
```

**Expected behavior**
Merge the meta data from the replies of the ChatGenerator + meta data passed as a run time variable

**To Reproduce**
Run the sample code

Hints from issue discussion:
I see what I did wrong. I can fix this.

Commit message: add pylint ignore flag to .run()

Patch to review:
diff --git a/haystack/components/builders/answer_builder.py b/haystack/components/builders/answer_builder.py
index 1577fb3e99..859400c58e 100644
--- a/haystack/components/builders/answer_builder.py
+++ b/haystack/components/builders/answer_builder.py
@@ -58,7 +58,7 @@ def __init__(self, pattern: Optional[str] = None, reference_pattern: Optional[st
         self.reference_pattern = reference_pattern
 
     @component.output_types(answers=List[GeneratedAnswer])
-    def run(
+    def run(  # pylint: disable=too-many-positional-arguments
         self,
         query: str,
         replies: Union[List[str], List[ChatMessage]],
@@ -111,10 +111,13 @@ def run(
         pattern = pattern or self.pattern
         reference_pattern = reference_pattern or self.reference_pattern
         all_answers = []
-        for reply, metadata in zip(replies, meta):
+        for reply, given_metadata in zip(replies, meta):
             # Extract content from ChatMessage objects if reply is a ChatMessages, else use the string as is
             extracted_reply: str = reply.content if isinstance(reply, ChatMessage) else reply  # type: ignore
-            extracted_metadata = reply.meta if isinstance(reply, ChatMessage) else metadata
+            extracted_metadata = reply.meta if isinstance(reply, ChatMessage) else {}
+
+            extracted_metadata = {**extracted_metadata, **given_metadata}
+
             referenced_docs = []
             if documents:
                 if reference_pattern:
diff --git a/releasenotes/notes/merge_runtime_meta_in_answerbuilder-4103b4ca4590aea5.yaml b/releasenotes/notes/merge_runtime_meta_in_answerbuilder-4103b4ca4590aea5.yaml
new file mode 100644
index 0000000000..ceab3e07f6
--- /dev/null
+++ b/releasenotes/notes/merge_runtime_meta_in_answerbuilder-4103b4ca4590aea5.yaml
@@ -0,0 +1,4 @@
+---
+fixes:
+  - |
+    When meta is passed into AnswerBuilder.run(), it is now merged into GeneratedAnswer meta
diff --git a/test/components/builders/test_answer_builder.py b/test/components/builders/test_answer_builder.py
index 46c40e3422..9c220ef477 100644
--- a/test/components/builders/test_answer_builder.py
+++ b/test/components/builders/test_answer_builder.py
@@ -36,6 +36,16 @@ def test_run_meta_is_an_empty_list(self):
         assert answers[0].documents == []
         assert isinstance(answers[0], GeneratedAnswer)
 
+    def test_run_with_meta(self):
+        component = AnswerBuilder()
+        output = component.run(query="query", replies=["reply1"], meta=[{"test": "meta"}])
+        answers = output["answers"]
+        assert answers[0].data == "reply1"
+        assert answers[0].meta == {"test": "meta"}
+        assert answers[0].query == "query"
+        assert answers[0].documents == []
+        assert isinstance(answers[0], GeneratedAnswer)
+
     def test_run_without_pattern(self):
         component = AnswerBuilder()
         output = component.run(query="test query", replies=["Answer: AnswerString"], meta=[{}])
@@ -272,3 +282,45 @@ def test_run_with_chat_message_replies_with_pattern_set_at_runtime(self):
         assert answers[0].query == "test query"
         assert answers[0].documents == []
         assert isinstance(answers[0], GeneratedAnswer)
+
+    def test_run_with_chat_message_replies_with_meta_set_at_run_time(self):
+        component = AnswerBuilder()
+        replies = [
+            ChatMessage(
+                content="AnswerString",
+                role=ChatRole.ASSISTANT,
+                name=None,
+                meta={
+                    "model": "gpt-4o-mini",
+                    "index": 0,
+                    "finish_reason": "stop",
+                    "usage": {"prompt_tokens": 32, "completion_tokens": 153, "total_tokens": 185},
+                },
+            )
+        ]
+        output = component.run(query="test query", replies=replies, meta=[{"test": "meta"}])
+        answers = output["answers"]
+        assert len(answers) == 1
+        assert answers[0].data == "AnswerString"
+        assert answers[0].meta == {
+            "model": "gpt-4o-mini",
+            "index": 0,
+            "finish_reason": "stop",
+            "usage": {"prompt_tokens": 32, "completion_tokens": 153, "total_tokens": 185},
+            "test": "meta",
+        }
+        assert answers[0].query == "test query"
+        assert answers[0].documents == []
+        assert isinstance(answers[0], GeneratedAnswer)
+
+    def test_run_with_chat_message_no_meta_with_meta_set_at_run_time(self):
+        component = AnswerBuilder()
+        replies = [ChatMessage(content="AnswerString", role=ChatRole.ASSISTANT, name=None, meta={})]
+        output = component.run(query="test query", replies=replies, meta=[{"test": "meta"}])
+        answers = output["answers"]
+        assert len(answers) == 1
+        assert answers[0].data == "AnswerString"
+        assert answers[0].meta == {"test": "meta"}
+        assert answers[0].query == "test query"
+        assert answers[0].documents == []
+        assert isinstance(answers[0], GeneratedAnswer)


Code review comments:
diff_hunk: @@ -111,10 +111,13 @@ def run(
         pattern = pattern or self.pattern
         reference_pattern = reference_pattern or self.reference_pattern
         all_answers = []
-        for reply, metadata in zip(replies, meta):
+        for reply, given_metadata in zip(replies, meta):
             # Extract content from ChatMessage objects if reply is a ChatMessages, else use the string as is
             extracted_reply: str = reply.content if isinstance(reply, ChatMessage) else reply  # type: ignore
path: haystack/components/builders/answer_builder.py
text: @user1:
(sorry I've left it pending yesterday :) )

not related to changes, but can this line be like the following?

```python
extracted_reply = reply.content if isinstance(reply, ChatMessage) else str(reply)
```

this way we should be able to remove the `type: ignore` annotation

@author:
Yep, that works!