Files
wehub-resource-sync c56bef871b
Sync docs with Docusaurus / sync (push) Waiting to run
Tests / Check if changed (push) Waiting to run
Tests / format (push) Blocked by required conditions
Tests / check-imports (push) Blocked by required conditions
Tests / Unit / macos-latest (push) Blocked by required conditions
Tests / Unit / ubuntu-latest (push) Blocked by required conditions
Tests / Unit / windows-latest (push) Blocked by required conditions
Tests / mypy (push) Blocked by required conditions
Tests / Integration / ubuntu-latest (push) Blocked by required conditions
Tests / Integration / macos-latest (push) Blocked by required conditions
Tests / Integration / windows-latest (push) Blocked by required conditions
Tests / notify-slack-on-failure (push) Blocked by required conditions
Tests / Mark tests as completed (push) Blocked by required conditions
Update Platform Components Table / update (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Docker image release / Build base image (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:22:28 +08:00

259 lines
8.9 KiB
Python

# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import json
import mimetypes
import tempfile
from collections import defaultdict
from dataclasses import replace
from math import inf
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, overload
from numpy import exp, ndarray
from haystack import logging
if TYPE_CHECKING:
from haystack.dataclasses import Document
CUSTOM_MIMETYPES = {
# we add markdown because it is not added by the mimetypes module
# see https://github.com/python/cpython/pull/17995
".md": "text/markdown",
".markdown": "text/markdown",
# we add msg because it is not added by the mimetypes module
".msg": "application/vnd.ms-outlook",
}
logger = logging.getLogger(__name__)
def expand_page_range(page_range: list[str | int]) -> list[int]:
"""
Takes a list of page numbers and ranges and expands them into a list of page numbers.
For example, given a page_range=['1-3', '5', '8', '10-12'] the function will return [1, 2, 3, 5, 8, 10, 11, 12]
:param page_range: List of page numbers and ranges
:returns:
An expanded list of page integers
:raises ValueError:
If any element is not a valid integer or a range string in the format `'start-end'`.
"""
expanded_page_range = []
for page in page_range:
if isinstance(page, int):
# check if it's a range wrongly passed as an integer expression
if "-" in str(page):
msg = "range must be a string in the format 'start-end'"
raise ValueError(f"Invalid page range: {page} - {msg}")
expanded_page_range.append(page)
elif isinstance(page, str) and page.isdigit():
expanded_page_range.append(int(page))
elif isinstance(page, str) and "-" in page:
parts = page.split("-", maxsplit=1)
if not parts[0].isdigit() or not parts[1].isdigit():
msg = "range must be a string in the format 'start-end'"
raise ValueError(f"Invalid page range: {page} - {msg}")
start, end = parts
expanded_page_range.extend(range(int(start), int(end) + 1))
else:
msg = "range must be a string in the format 'start-end' or an integer"
raise ValueError(f"Invalid page range: {page} - {msg}")
if not expanded_page_range:
raise ValueError("No valid page numbers or ranges found in the input list")
return expanded_page_range
@overload
def expit(x: float) -> float: ...
@overload
def expit(x: ndarray[Any, Any]) -> ndarray[Any, Any]: ...
def expit(x: float | ndarray[Any, Any]) -> float | ndarray[Any, Any]:
"""
Compute logistic sigmoid function. Maps input values to a range between 0 and 1
:param x: input value. Can be a scalar or a numpy array.
"""
return 1 / (1 + exp(-x))
def _guess_mime_type(path: Path) -> str | None:
"""
Guess the MIME type of the provided file path.
:param path: The file path to get the MIME type for.
:returns: The MIME type of the provided file path, or `None` if the MIME type cannot be determined.
"""
extension = path.suffix.lower()
mime_type = mimetypes.guess_type(path.as_posix())[0]
# lookup custom mappings if the mime type is not found
return CUSTOM_MIMETYPES.get(extension, mime_type)
def _get_output_dir(out_dir: str) -> str:
"""
Find or create a writable directory for saving status files.
Tries in the following order:
1. ~/.haystack/{out_dir}
2. {tempdir}/haystack/{out_dir}
3. ./.haystack/{out_dir}
:raises RuntimeError: If no directory could be created.
:returns:
The path to the created directory.
"""
candidates = [
Path.home() / ".haystack" / out_dir,
Path(tempfile.gettempdir()) / "haystack" / out_dir,
Path.cwd() / ".haystack" / out_dir,
]
for candidate in candidates:
try:
candidate.mkdir(parents=True, exist_ok=True)
return str(candidate)
except Exception:
continue
raise RuntimeError(
f"Could not create a writable directory for output files in any of the following locations: {candidates}"
)
def _deduplicate_documents(documents: list["Document"]) -> list["Document"]:
"""
Deduplicate a list of documents by their id keeping the duplicate with the highest score if a score is present.
:param documents: List of documents to deduplicate.
:returns: List of deduplicated documents.
"""
# Keep for each Document id the one with the highest score
highest_scoring_docs: dict[str, "Document"] = {}
for doc in documents:
score = doc.score if doc.score is not None else -inf
best = highest_scoring_docs.get(doc.id)
if best is None or score > (best.score if best.score is not None else -inf):
highest_scoring_docs[doc.id] = doc
return list(highest_scoring_docs.values())
def _reciprocal_rank_fusion(
document_lists: list[list["Document"]], weights: list[float] | None = None
) -> list["Document"]:
"""
Merge multiple ranked lists of Documents using Reciprocal Rank Fusion, deduplicating across lists.
See the original paper: https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf
The constant k is set to 61 (60 was suggested by the original paper, plus 1 as python lists are 0-based and the
paper used 1-based ranking).
:param document_lists: A list of ranked document lists to fuse.
:param weights: Optional per-list weights. Defaults to equal weights.
:returns:
Deduplicated list of documents with updated RRF scores.
"""
if not document_lists:
return []
k = 61
scores_map: dict = defaultdict(int)
documents_map: dict = {}
resolved_weights = weights if weights else [1 / len(document_lists)] * len(document_lists)
for documents, weight in zip(document_lists, resolved_weights, strict=True):
for rank, doc in enumerate(documents):
scores_map[doc.id] += (weight * len(document_lists)) / (k + rank)
documents_map[doc.id] = doc
for _id in scores_map:
scores_map[_id] /= len(document_lists) / k
return [replace(doc, score=scores_map[doc.id]) for doc in documents_map.values()]
@overload
def _parse_dict_from_json(
text: str, expected_keys: list[str] | None = ..., raise_on_failure: Literal[True] = ...
) -> dict[str, Any]: ...
@overload
def _parse_dict_from_json(
text: str, expected_keys: list[str] | None = ..., raise_on_failure: Literal[False] = ...
) -> dict[str, Any] | None: ...
@overload
def _parse_dict_from_json(
text: str, expected_keys: list[str] | None = ..., raise_on_failure: bool = ...
) -> dict[str, Any] | None: ...
def _parse_dict_from_json(
text: str, expected_keys: list[str] | None = None, raise_on_failure: bool = True
) -> dict[str, Any] | None:
"""
Parses a JSON string containing a dictionary.
:param text: The string to parse.
:param expected_keys: A list of keys that must be present in the parsed dictionary.
:param raise_on_failure: If True, raises an exception on failure. If False, logs a warning and returns None.
:return: The parsed dictionary, or None if parsing fails and raise_on_failure is False.
:raises json.JSONDecodeError: If the text is not valid JSON and raise_on_failure is True.
:raises ValueError: If the parsed object is not a dictionary or has missing expected keys,
and `raise_on_failure` is True.
"""
cleaned_text = text.strip()
try:
parsed_json = json.loads(cleaned_text)
except json.JSONDecodeError as e:
if raise_on_failure:
raise e
logger.warning("Failed to parse JSON from text: {text}. Error: {error}", text=text, error=e)
return None
if not isinstance(parsed_json, dict):
if raise_on_failure:
raise ValueError(f"Expected a JSON object containing a dictionary but got {type(parsed_json).__name__}")
logger.warning(
"Expected a JSON object containing a dictionary but got {type}. Returning None",
type=type(parsed_json).__name__,
)
return None
if not expected_keys:
return parsed_json
missing_keys = [key for key in expected_keys if key not in parsed_json]
if missing_keys:
if raise_on_failure:
raise ValueError(f"Missing expected keys in JSON: {missing_keys}. Got keys: {list(parsed_json.keys())}")
logger.warning(
"Missing expected keys in JSON: {missing_keys}. Got keys: {keys}",
missing_keys=missing_keys,
keys=list(parsed_json.keys()),
)
return None
return parsed_json
def _normalize_metadata_field_name(metadata_field: str) -> str:
"""
Normalizes a metadata field name by removing the "meta." prefix if present.
"""
return metadata_field[5:] if metadata_field.startswith("meta.") else metadata_field