Repository: deepset-ai/haystack

Issue:
`print_answers` doesn't work with output of `run_batch` for queries
**Describe the bug**
`print_answers` breaks when run with the output of a query pipeline's `run_batch`.

**Error message**
```
File "/.../miniforge3/envs/poc/lib/python3.8/site-packages/haystack/utils/export_utils.py", line 56, in print_answers
    for field in fields_to_keep_by_level[details][type(ans)]
KeyError: <class 'list'>
```

**Expected behavior**
I expected a "pretty" print of a list of queries with their respective answers.

**Additional context**
Issue arose when following the [Build a Scalable Question Answering System](https://haystack.deepset.ai/tutorials/03_scalable_qa_system) tutorial. I modified the tutorial code by using a list of queries instead of a single `str` query, and then running `run_batch` instead of `run` on the pipeline.

**To Reproduce**
Running the script below will produce an error at the end.
```
import os
from haystack import Pipeline
from haystack.utils import launch_opensearch, fetch_archive_from_http, print_answers
from haystack.document_stores import OpenSearchDocumentStore
from haystack.nodes import TextConverter, PreProcessor, BM25Retriever, FARMReader

launch_opensearch()

host = os.environ.get('OPENSEARCH_HOST', 'localhost')
document_store = OpenSearchDocumentStore(
    host=host,
    username='admin',
    password='admin',
    index='document'
)

doc_dir = "data/qa_101"
fetch_archive_from_http(
    url="https://s3.eu-central-1.amazonaws.com/deepset.ai-farm-qa/datasets/documents/wiki_gameofthrones_txt1.zip",
    output_dir=doc_dir
)

indexing_pipeline = Pipeline()
text_converter = TextConverter()
preprocessor = PreProcessor(
    clean_whitespace=True,
    clean_header_footer=True,
    clean_empty_lines=True,
    split_by='word',
    split_length=200,
    split_overlap=20,
    split_respect_sentence_boundary=True,
)

indexing_pipeline.add_node(component=text_converter, name='TextConverter', inputs=['File'])
indexing_pipeline.add_node(component=preprocessor, name='PreProcessor', inputs=['TextConverter'])
indexing_pipeline.add_node(component=document_store, name='DocumentStore', inputs=['PreProcessor'])

files_to_index = [os.path.join(doc_dir, f) for f in os.listdir(doc_dir)]

indexing_pipeline.run_batch(file_paths=files_to_index)

retriever = BM25Retriever(document_store)
reader = FARMReader('deepset/roberta-base-squad2', use_gpu=False)

query_pipeline = Pipeline()
query_pipeline.add_node(component=retriever, name='Retriever', inputs=['Query'])
query_pipeline.add_node(component=reader, name='Reader', inputs=['Retriever'])

queries = [
    'Who is the father of Arya Stark?',
    'Who is the sister of Arya Stark?',
]

prediction = query_pipeline.run_batch(
    queries,
    params={
        'Retriever': {'top_k': 10},
        'Reader': {'top_k': 3}
    }
)

print_answers(
    prediction,
    details='minimum'  # error with `minimum` or `medium`
)
```

**FAQ Check**
- [x] Have you had a look at [our new FAQ page](https://docs.haystack.deepset.ai/docs/faq)?

**System:**
 - OS: MacOS 12.5
 - GPU/CPU: CPU
 - Haystack version (commit or version number): 1.13.2
 - DocumentStore: OpenSearch
 - Reader: `FARMReader('deepset/roberta-base-squad2')`
 - Retriever: `BM25Retriever`



Discussion:
Thanks for reporting this @vbernardes 
I think it originates because of [this line of code](https://github.com/deepset-ai/haystack/blob/13c4ff1b52983a6024ac84aed6423681887954da/haystack/utils/export_utils.py#L53) which doesn't check if the 'answers' is actually an array of arrays, which in the case of `run_batch`, it is 👍 Nice catch!
Thanks!
This seems a simple enough issue to get started. I'd be glad to work on a PR if no one is assigned to this.
Hey @vbernardes - We're always open for contributions so please do feel free to do that. Someone from the team will review it 🙌 

Resolving patch:
diff --git a/haystack/utils/export_utils.py b/haystack/utils/export_utils.py
--- a/haystack/utils/export_utils.py
+++ b/haystack/utils/export_utils.py
@@ -41,37 +41,45 @@ def print_answers(results: dict, details: str = "all", max_text_len: Optional[in
             "Try print_documents or print_questions."
         )
 
-    if "query" in results.keys():
-        print(f"\nQuery: {results['query']}\nAnswers:")
-
-    answers = results["answers"]
     pp = pprint.PrettyPrinter(indent=4)
 
-    # Filter the results by detail level
-    filtered_answers = []
-    if details in fields_to_keep_by_level.keys():
-        for ans in answers:
-            filtered_ans = {
-                field: getattr(ans, field)
-                for field in fields_to_keep_by_level[details][type(ans)]
-                if getattr(ans, field) is not None
-            }
-            filtered_answers.append(filtered_ans)
-    elif details == "all":
-        filtered_answers = answers
-    else:
-        valid_values = ", ".join(fields_to_keep_by_level.keys()) + " and 'all'"
-        logging.warn("print_answers received details='%s', which was not understood. ", details)
-        logging.warn("Valid values are %s. Using 'all'.", valid_values)
-        filtered_answers = answers
-
-    # Shorten long text fields
-    if max_text_len is not None:
-        for ans in answers:
-            if getattr(ans, "context") and len(ans.context) > max_text_len:
-                ans.context = ans.context[:max_text_len] + "..."
-
-    pp.pprint(filtered_answers)
+    queries = []
+    if "query" in results.keys():  # results came from a `run` call
+        queries = [results["query"]]
+        answers_lists = [results["answers"]]
+    elif "queries" in results.keys():  # results came from a `run_batch` call
+        queries = results["queries"]
+        answers_lists = results["answers"]
+
+    for query_idx, answers in enumerate(answers_lists):
+        # Filter the results by detail level
+        filtered_answers = []
+        if details in fields_to_keep_by_level.keys():
+            for ans in answers:
+                filtered_ans = {
+                    field: getattr(ans, field)
+                    for field in fields_to_keep_by_level[details][type(ans)]
+                    if getattr(ans, field) is not None
+                }
+                filtered_answers.append(filtered_ans)
+        elif details == "all":
+            filtered_answers = answers
+        else:
+            valid_values = ", ".join(fields_to_keep_by_level.keys()) + " and 'all'"
+            logging.warn("print_answers received details='%s', which was not understood. ", details)
+            logging.warn("Valid values are %s. Using 'all'.", valid_values)
+            filtered_answers = answers
+
+        # Shorten long text fields
+        if max_text_len is not None:
+            for ans in answers:
+                if getattr(ans, "context") and len(ans.context) > max_text_len:
+                    ans.context = ans.context[:max_text_len] + "..."
+
+        if len(queries) > 0:
+            pp.pprint(f"Query: {queries[query_idx]}")
+        pp.pprint("Answers:")
+        pp.pprint(filtered_answers)
 
 
 def print_documents(


Test patch:
diff --git a/test/others/test_utils.py b/test/others/test_utils.py
--- a/test/others/test_utils.py
+++ b/test/others/test_utils.py
@@ -15,6 +15,7 @@
 from haystack.errors import OpenAIRateLimitError
 from haystack.environment import set_pytorch_secure_model_loading
 from haystack.schema import Answer, Document, Span, Label
+from haystack.utils import print_answers
 from haystack.utils.deepsetcloud import DeepsetCloud, DeepsetCloudExperiments
 from haystack.utils.labels import aggregate_labels
 from haystack.utils.preprocessing import convert_files_to_docs, tika_convert_files_to_docs
@@ -1578,3 +1579,45 @@ def test_aggregate_labels_meta_aggregations_closed_domain(self, filter_meta_labe
                 assert l.filters["from_meta"] == l.meta["from_meta"]
                 assert "_id" in l.filters
                 assert multi_label.filters == l.filters
+
+
+def test_print_answers_run():
+    with mock.patch("pprint.PrettyPrinter.pprint") as pprint:
+        query_string = "Who is the father of Arya Stark?"
+        run_result = {
+            "query": query_string,
+            "answers": [Answer(answer="Eddard", context="Eddard"), Answer(answer="Ned", context="Eddard")],
+        }
+
+        print_answers(run_result, details="minimum")
+
+        expected_pprint_string = f"Query: {query_string}"
+        pprint.assert_any_call(expected_pprint_string)
+
+        expected_pprint_answers = [
+            {"answer": answer.answer, "context": answer.context}  # filtered fields for minimum
+            for answer in run_result["answers"]
+        ]
+        pprint.assert_any_call(expected_pprint_answers)
+
+
+def test_print_answers_run_batch():
+    with mock.patch("pprint.PrettyPrinter.pprint") as pprint:
+        queries = ["Who is the father of Arya Stark?", "Who is the sister of Arya Stark?"]
+        answers = [
+            [Answer(answer="Eddard", context="Eddard"), Answer(answer="Ned", context="Eddard")],
+            [Answer(answer="Sansa", context="Sansa")],
+        ]
+        run_batch_result = {"queries": queries, "answers": answers}
+
+        print_answers(run_batch_result, details="minimum")
+
+        for query in queries:
+            expected_pprint_string = f"Query: {query}"
+            pprint.assert_any_call(expected_pprint_string)
+        for answer_list in answers:
+            expected_pprint_answers = [
+                {"answer": answer.answer, "context": answer.context}  # filtered fields for minimum
+                for answer in answer_list
+            ]
+            pprint.assert_any_call(expected_pprint_answers)