Coverage for haystack/utils/misc.py: 95%
99 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 09:42 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 09:42 +0000
1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2#
3# SPDX-License-Identifier: Apache-2.0
5import json
6import mimetypes
7import tempfile
8from collections import defaultdict
9from dataclasses import replace
10from math import inf
11from pathlib import Path
12from typing import TYPE_CHECKING, Any, Literal, overload
14from numpy import exp, ndarray
16from haystack import logging
18if TYPE_CHECKING:
19 from haystack.dataclasses import Document
21CUSTOM_MIMETYPES = {
22 # we add markdown because it is not added by the mimetypes module
23 # see https://github.com/python/cpython/pull/17995
24 ".md": "text/markdown",
25 ".markdown": "text/markdown",
26 # we add msg because it is not added by the mimetypes module
27 ".msg": "application/vnd.ms-outlook",
28}
30logger = logging.getLogger(__name__)
33def expand_page_range(page_range: list[str | int]) -> list[int]:
34 """
35 Takes a list of page numbers and ranges and expands them into a list of page numbers.
37 For example, given a page_range=['1-3', '5', '8', '10-12'] the function will return [1, 2, 3, 5, 8, 10, 11, 12]
39 :param page_range: List of page numbers and ranges
40 :returns:
41 An expanded list of page integers
42 :raises ValueError:
43 If any element is not a valid integer or a range string in the format `'start-end'`.
45 """
46 expanded_page_range = []
48 for page in page_range:
49 if isinstance(page, int):
50 # check if it's a range wrongly passed as an integer expression
51 if "-" in str(page):
52 msg = "range must be a string in the format 'start-end'"
53 raise ValueError(f"Invalid page range: {page} - {msg}")
54 expanded_page_range.append(page)
56 elif isinstance(page, str) and page.isdigit():
57 expanded_page_range.append(int(page))
59 elif isinstance(page, str) and "-" in page:
60 parts = page.split("-", maxsplit=1)
61 if not parts[0].isdigit() or not parts[1].isdigit():
62 msg = "range must be a string in the format 'start-end'"
63 raise ValueError(f"Invalid page range: {page} - {msg}")
64 start, end = int(parts[0]), int(parts[1])
65 if start > end:
66 msg = "start must be less than or equal to end"
67 raise ValueError(f"Invalid page range: '{parts[0]}-{parts[1]}' - {msg}")
68 expanded_page_range.extend(range(start, end + 1))
70 else:
71 msg = "range must be a string in the format 'start-end' or an integer"
72 raise ValueError(f"Invalid page range: {page} - {msg}")
74 if not expanded_page_range:
75 raise ValueError("No valid page numbers or ranges found in the input list")
77 return expanded_page_range
80@overload
81def expit(x: float) -> float: ...
82@overload
83def expit(x: ndarray[Any, Any]) -> ndarray[Any, Any]: ...
84def expit(x: float | ndarray[Any, Any]) -> float | ndarray[Any, Any]:
85 """
86 Compute logistic sigmoid function. Maps input values to a range between 0 and 1
88 :param x: input value. Can be a scalar or a numpy array.
89 """
90 return 1 / (1 + exp(-x))
93def _guess_mime_type(path: Path) -> str | None:
94 """
95 Guess the MIME type of the provided file path.
97 :param path: The file path to get the MIME type for.
99 :returns: The MIME type of the provided file path, or `None` if the MIME type cannot be determined.
100 """
101 extension = path.suffix.lower()
102 mime_type = mimetypes.guess_type(path.as_posix())[0]
103 # lookup custom mappings if the mime type is not found
104 return CUSTOM_MIMETYPES.get(extension, mime_type)
107def _get_output_dir(out_dir: str) -> str:
108 """
109 Find or create a writable directory for saving status files.
111 Tries in the following order:
113 1. ~/.haystack/{out_dir}
114 2. {tempdir}/haystack/{out_dir}
115 3. ./.haystack/{out_dir}
117 :raises RuntimeError: If no directory could be created.
118 :returns:
119 The path to the created directory.
120 """
122 candidates = [
123 Path.home() / ".haystack" / out_dir,
124 Path(tempfile.gettempdir()) / "haystack" / out_dir,
125 Path.cwd() / ".haystack" / out_dir,
126 ]
128 for candidate in candidates:
129 try:
130 candidate.mkdir(parents=True, exist_ok=True)
131 return str(candidate)
132 except Exception:
133 continue
135 raise RuntimeError(
136 f"Could not create a writable directory for output files in any of the following locations: {candidates}"
137 )
140def _deduplicate_documents(documents: list["Document"]) -> list["Document"]:
141 """
142 Deduplicate a list of documents by their id keeping the duplicate with the highest score if a score is present.
144 :param documents: List of documents to deduplicate.
145 :returns: List of deduplicated documents.
146 """
147 # Keep for each Document id the one with the highest score
148 highest_scoring_docs: dict[str, "Document"] = {}
149 for doc in documents:
150 score = doc.score if doc.score is not None else -inf
151 best = highest_scoring_docs.get(doc.id)
153 if best is None or score > (best.score if best.score is not None else -inf):
154 highest_scoring_docs[doc.id] = doc
156 return list(highest_scoring_docs.values())
159def _reciprocal_rank_fusion(
160 document_lists: list[list["Document"]], weights: list[float] | None = None
161) -> list["Document"]:
162 """
163 Merge multiple ranked lists of Documents using Reciprocal Rank Fusion, deduplicating across lists.
165 See the original paper: https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf
167 The constant k is set to 61 (60 was suggested by the original paper, plus 1 as python lists are 0-based and the
168 paper used 1-based ranking).
170 :param document_lists: A list of ranked document lists to fuse.
171 :param weights: Optional per-list weights. Defaults to equal weights.
172 :returns:
173 Deduplicated list of documents with updated RRF scores.
174 """
175 if not document_lists:
176 return []
178 k = 61
179 scores_map: dict = defaultdict(int)
180 documents_map: dict = {}
181 resolved_weights = weights if weights else [1 / len(document_lists)] * len(document_lists)
183 for documents, weight in zip(document_lists, resolved_weights, strict=True):
184 for rank, doc in enumerate(documents):
185 scores_map[doc.id] += (weight * len(document_lists)) / (k + rank)
186 documents_map[doc.id] = doc
188 for _id in scores_map:
189 scores_map[_id] /= len(document_lists) / k
191 return [replace(doc, score=scores_map[doc.id]) for doc in documents_map.values()]
194@overload
195def _parse_dict_from_json(
196 text: str, expected_keys: list[str] | None = ..., raise_on_failure: Literal[True] = ...
197) -> dict[str, Any]: ...
198@overload
199def _parse_dict_from_json(
200 text: str, expected_keys: list[str] | None = ..., raise_on_failure: Literal[False] = ...
201) -> dict[str, Any] | None: ...
202@overload
203def _parse_dict_from_json(
204 text: str, expected_keys: list[str] | None = ..., raise_on_failure: bool = ...
205) -> dict[str, Any] | None: ...
206def _parse_dict_from_json(
207 text: str, expected_keys: list[str] | None = None, raise_on_failure: bool = True
208) -> dict[str, Any] | None:
209 """
210 Parses a JSON string containing a dictionary.
212 :param text: The string to parse.
213 :param expected_keys: A list of keys that must be present in the parsed dictionary.
214 :param raise_on_failure: If True, raises an exception on failure. If False, logs a warning and returns None.
216 :return: The parsed dictionary, or None if parsing fails and raise_on_failure is False.
217 :raises json.JSONDecodeError: If the text is not valid JSON and raise_on_failure is True.
218 :raises ValueError: If the parsed object is not a dictionary or has missing expected keys,
219 and `raise_on_failure` is True.
220 """
221 cleaned_text = text.strip()
223 try:
224 parsed_json = json.loads(cleaned_text)
225 except json.JSONDecodeError as e:
226 if raise_on_failure:
227 raise e
228 logger.warning("Failed to parse JSON from text: {text}. Error: {error}", text=text, error=e)
229 return None
231 if not isinstance(parsed_json, dict):
232 if raise_on_failure:
233 raise ValueError(f"Expected a JSON object containing a dictionary but got {type(parsed_json).__name__}")
234 logger.warning(
235 "Expected a JSON object containing a dictionary but got {type}. Returning None",
236 type=type(parsed_json).__name__,
237 )
238 return None
240 if not expected_keys:
241 return parsed_json
243 missing_keys = [key for key in expected_keys if key not in parsed_json]
244 if missing_keys:
245 if raise_on_failure:
246 raise ValueError(f"Missing expected keys in JSON: {missing_keys}. Got keys: {list(parsed_json.keys())}")
247 logger.warning(
248 "Missing expected keys in JSON: {missing_keys}. Got keys: {keys}",
249 missing_keys=missing_keys,
250 keys=list(parsed_json.keys()),
251 )
252 return None
254 return parsed_json
257def _normalize_metadata_field_name(metadata_field: str) -> str:
258 """
259 Normalizes a metadata field name by removing the "meta." prefix if present.
260 """
261 return metadata_field[5:] if metadata_field.startswith("meta.") else metadata_field