diff --git a/ai4rag/components/optimization/__init__.py b/ai4rag/components/optimization/__init__.py index fe351579..3abbdc3e 100644 --- a/ai4rag/components/optimization/__init__.py +++ b/ai4rag/components/optimization/__init__.py @@ -2,7 +2,10 @@ # Copyright IBM Corp. 2026 # SPDX-License-Identifier: Apache-2.0 # ----------------------------------------------------------------------------- -from ai4rag.components.optimization.rag_templates_optimization import OptimizationResult, run_rag_optimization +from ai4rag.components.optimization.rag_templates_optimization import ( + OptimizationResult, + run_rag_optimization, +) from ai4rag.components.optimization.search_space_preparation import SearchSpaceReport, prepare_search_space_report __all__ = [ diff --git a/ai4rag/components/optimization/judge_selection.py b/ai4rag/components/optimization/judge_selection.py new file mode 100644 index 00000000..09770d47 --- /dev/null +++ b/ai4rag/components/optimization/judge_selection.py @@ -0,0 +1,165 @@ +# ----------------------------------------------------------------------------- +# Copyright IBM Corp. 2026 +# SPDX-License-Identifier: Apache-2.0 +# ----------------------------------------------------------------------------- +import logging +from typing import Any + +import numpy as np +from docling_core.types.doc import DoclingDocument + +from ai4rag import handler +from ai4rag.core.experiment.benchmark_data import BenchmarkData +from ai4rag.core.experiment.utils import build_evaluation_data, query_rag +from ai4rag.evaluator.base_evaluator import MetricType +from ai4rag.evaluator.llmaj_evaluator import LLMaJConfig, LLMaJEvaluator +from ai4rag.evaluator.unitxt_evaluator import UnitxtEvaluator +from ai4rag.rag.chunking.langchain_chunker import LangChainChunker +from ai4rag.rag.embedding.base_model import BaseEmbeddingModel +from ai4rag.rag.foundation_models.base_model import BaseFoundationModel +from ai4rag.rag.retrieval.retriever import Retriever +from ai4rag.rag.template.simple_rag_template import SimpleRAG +from ai4rag.rag.vector_store.chroma import ChromaVectorStore + +_logger = logging.getLogger("judge-selection") +_logger.addHandler(handler) + + +def calibration_subset_size(total_rows: int) -> int: + """Return calibration row count: min(20, 10% of benchmark rows).""" + if total_rows <= 0: + return 0 + return max(1, min(20, int(total_rows * 0.1))) + + +def select_judge_model( + *, + evaluator: str, + judge_model_id: str | None, + generation_models: list[BaseFoundationModel], + embedding_models: list[BaseEmbeddingModel], + benchmark_data: BenchmarkData, + documents: list[DoclingDocument], + ogx_base_url: str, + ogx_api_key: str, + max_threads: int = 10, +) -> str | None: + """ + Resolve the judge model for an optimization run. + + When ``evaluator`` is ``unitxt``, returns ``None``. When ``judge_model_id`` + is provided, returns it unchanged. Otherwise auto-selects from the generation + model pool using faithfulness calibration against Unitxt. + """ + if evaluator != "judge": + return None + + if judge_model_id: + return judge_model_id + + candidates = [model.model_id for model in generation_models] + if not candidates: + raise ValueError("At least one generation model is required to select a judge model.") + if len(candidates) == 1: + return candidates[0] + + return _calibrate_judge_model( + candidates=candidates, + generation_models=generation_models, + embedding_models=embedding_models, + benchmark_data=benchmark_data, + documents=documents, + ogx_base_url=ogx_base_url, + ogx_api_key=ogx_api_key, + max_threads=max_threads, + ) + + +def _calibrate_judge_model( + *, + candidates: list[str], + generation_models: list[BaseFoundationModel], + embedding_models: list[BaseEmbeddingModel], + benchmark_data: BenchmarkData, + documents: list[DoclingDocument], + ogx_base_url: str, + ogx_api_key: str, + max_threads: int, +) -> str: + subset_size = calibration_subset_size(len(benchmark_data.questions)) + calibration_benchmark = benchmark_data.get_random_sample(n_records=subset_size, random_seed=17) + eval_data = _run_reference_rag( + foundation_model=generation_models[0], + embedding_model=embedding_models[0], + documents=documents, + benchmark_data=calibration_benchmark, + max_threads=max_threads, + ) + + unitxt_scores = UnitxtEvaluator().evaluate_metrics(eval_data, [MetricType.FAITHFULNESS]) + reference = _ordered_question_scores(unitxt_scores, MetricType.FAITHFULNESS) + + rankings: list[dict[str, Any]] = [] + for model_id in candidates: + judge = LLMaJEvaluator( + LLMaJConfig( + base_url=ogx_base_url.rstrip("/") + "/v1", + api_key=ogx_api_key, + model=model_id, + ) + ) + judge_scores = judge.evaluate_metrics(eval_data, [MetricType.FAITHFULNESS]) + candidate_scores = _ordered_question_scores(judge_scores, MetricType.FAITHFULNESS) + correlation = _pearson_correlation(reference, candidate_scores) + rankings.append({"model_id": model_id, "judge_calibration_score": correlation}) + _logger.info("Judge calibration for %s: correlation=%.4f", model_id, correlation) + + rankings.sort( + key=lambda item: ( + item["judge_calibration_score"] if item["judge_calibration_score"] is not None else -2.0, + -candidates.index(item["model_id"]), + item["model_id"], + ), + reverse=True, + ) + selected = rankings[0]["model_id"] + _logger.info("Selected judge model: %s (calibration score=%s)", selected, rankings[0]["judge_calibration_score"]) + return selected + + +def _run_reference_rag( + *, + foundation_model: BaseFoundationModel, + embedding_model: BaseEmbeddingModel, + documents: list[DoclingDocument], + benchmark_data: BenchmarkData, + max_threads: int, +) -> list: + chunker = LangChainChunker(chunk_size=512, method="recursive", chunk_overlap=128) + chunks = chunker.split_documents(documents) + vector_store = ChromaVectorStore(embedding_model=embedding_model, collection_name="judge_calibration") + vector_store.add_documents(chunks) + retriever = Retriever(vector_store=vector_store, number_of_chunks=3, method="simple", search_mode="vector") + rag = SimpleRAG(foundation_model=foundation_model, retriever=retriever) + inference_response = query_rag( + rag=rag, + questions=list(benchmark_data.questions), + max_threads=max_threads, + ) + return build_evaluation_data(benchmark_data=benchmark_data, inference_response=inference_response) + + +def _ordered_question_scores(evaluation_result: dict, metric: str) -> list[float | None]: + question_scores = (evaluation_result.get("question_scores") or {}).get(metric) or {} + return [question_scores.get(qid) for qid in sorted(question_scores.keys())] + + +def _pearson_correlation(reference: list[float | None], candidate: list[float | None]) -> float | None: + paired = [(a, b) for a, b in zip(reference, candidate) if a is not None and b is not None] + if len(paired) < 2: + return None + ref_vals = np.array([pair[0] for pair in paired], dtype=float) + cand_vals = np.array([pair[1] for pair in paired], dtype=float) + if np.std(ref_vals) == 0 or np.std(cand_vals) == 0: + return None + return float(np.corrcoef(ref_vals, cand_vals)[0, 1]) diff --git a/ai4rag/components/optimization/rag_templates_optimization.py b/ai4rag/components/optimization/rag_templates_optimization.py index 501cafea..a5bd6e31 100644 --- a/ai4rag/components/optimization/rag_templates_optimization.py +++ b/ai4rag/components/optimization/rag_templates_optimization.py @@ -18,6 +18,9 @@ from ai4rag.components.utils.docling_io import load_docling_documents from ai4rag.core.experiment.experiment import AI4RAGExperiment from ai4rag.core.hpo.gam_opt import GAMOptSettings +from ai4rag.evaluator.base_evaluator import MetricType +from ai4rag.evaluator.llmaj_evaluator import LLMaJConfig, LLMaJEvaluator +from ai4rag.evaluator.unitxt_evaluator import UnitxtEvaluator from ai4rag.rag.embedding.ogx import OGXEmbeddingModel from ai4rag.rag.foundation_models.base_model import Language from ai4rag.rag.foundation_models.ogx import OGXFoundationModel @@ -32,6 +35,11 @@ MIN_MAX_RAG_PATTERNS_RANGE = (4, 20) DEFAULT_METRIC = "faithfulness" SUPPORTED_OPTIMIZATION_METRICS = frozenset({"faithfulness", "answer_correctness", "context_correctness"}) +STANDARD_EVALUATION_METRICS = ( + MetricType.ANSWER_CORRECTNESS, + MetricType.FAITHFULNESS, + MetricType.CONTEXT_CORRECTNESS, +) @dataclass @@ -61,6 +69,8 @@ def run_rag_optimization( # pylint: disable=too-many-locals,too-many-arguments, input_data_key: str = "", optimization_settings: dict | None = None, max_threads: int = 10, + evaluator: str = "judge", + judge_model_id: str | None = None, ) -> OptimizationResult: """Run a full AI4RAG optimization experiment and generate output artefacts. @@ -96,6 +106,11 @@ def run_rag_optimization( # pylint: disable=too-many-locals,too-many-arguments, RAG service during benchmark evaluation. Lower values reduce per-request concurrency (useful when each request carries more retrieved context). Defaults to ``10``. + evaluator + Evaluation backend: ``"judge"`` (default) or ``"unitxt"``. + judge_model_id + Judge model identifier for ``evaluator="judge"``. When omitted, the + value from the search-space report is used. Returns ------- @@ -120,12 +135,22 @@ def run_rag_optimization( # pylint: disable=too-many-locals,too-many-arguments, raise ValueError("vector_io_provider_id must be a non-empty string.") vector_io_provider_id = vector_io_provider_id.strip() + if evaluator not in ("judge", "unitxt"): + raise ValueError(f"Evaluator {evaluator!r} is not supported. Supported evaluators are ['judge', 'unitxt'].") + settings = _validate_optimization_settings(optimization_settings) optimization_metric = settings.get("metric") or DEFAULT_METRIC + if optimization_metric not in SUPPORTED_OPTIMIZATION_METRICS: raise ValueError( f"Optimization metric {optimization_metric} is not supported. " - f"Select one of {SUPPORTED_OPTIMIZATION_METRICS}." + f"Select one of {sorted(SUPPORTED_OPTIMIZATION_METRICS)}." + ) + + if evaluator == "judge" and not judge_model_id: + raise ValueError( + "judge_model_id is required when evaluator='judge'. " + "Provide it explicitly or run search-space preparation first." ) documents = load_docling_documents(extracted_text_path) @@ -133,8 +158,40 @@ def run_rag_optimization( # pylint: disable=too-many-locals,too-many-arguments, with open(search_space_report_path, "r", encoding="utf-8") as f: search_space_raw: dict[str, Any] = json.load(f) + evaluation_block = search_space_raw.get("evaluation") or {} + resolved_evaluator = evaluator + resolved_judge_model_id = judge_model_id or evaluation_block.get("judge_model_id") + if resolved_evaluator == "judge" and not resolved_judge_model_id: + raise ValueError( + "judge_model_id is required when evaluator='judge'. " + "Provide it explicitly or run search-space preparation first." + ) + + evaluator_kwargs: dict[str, Any] + if resolved_evaluator == "unitxt": + evaluator_kwargs = { + "evaluator": UnitxtEvaluator(), + "metrics": STANDARD_EVALUATION_METRICS, + } + else: + config = LLMaJConfig( + base_url=ogx_client.base_url.rstrip("/") + "/v1", + api_key=getattr(ogx_client, "api_key", "") or "", + model=resolved_judge_model_id or "", + ) + evaluator_kwargs = { + "evaluator": LLMaJEvaluator(config), + "metrics": STANDARD_EVALUATION_METRICS, + } + + evaluation_config = {"evaluator": resolved_evaluator} + if resolved_evaluator == "judge": + evaluation_config["judge_model_id"] = resolved_judge_model_id + params: list[Parameter] = [] for param_name, values in search_space_raw.items(): + if param_name in ("evaluation",): + continue if param_name in ("foundation_model", "embedding_model"): values = [_deserialize_model(m, ogx_client) for m in values] params.append(Parameter(param_name, "C", values=values)) @@ -161,7 +218,9 @@ def run_rag_optimization( # pylint: disable=too-many-locals,too-many-arguments, documents=documents, optimization_metric=optimization_metric, ogx_vector_io_provider_id=vector_io_provider_id, - max_threads=max_threads, + inference_max_threads=max_threads, + evaluation_config=evaluation_config, + **evaluator_kwargs, ) # --- Run the optimization loop --- @@ -199,12 +258,9 @@ def run_rag_optimization( # pylint: disable=too-many-locals,too-many-arguments, ogx_base_url=ogx_base_url, ) - # Attach scores to pattern data and write pattern.json - with (patt_dir / "pattern.json").open("w+", encoding="utf-8") as f: json_dump(pattern_data, f, indent=2) - # Write evaluation results evaluation_result_list = pattern.get("evaluation_results", []) with (patt_dir / "evaluation_results.json").open("w+", encoding="utf-8") as f: json_dump(evaluation_result_list, f, indent=2) diff --git a/ai4rag/components/optimization/search_space_preparation.py b/ai4rag/components/optimization/search_space_preparation.py index ca1d0a5e..5244a291 100644 --- a/ai4rag/components/optimization/search_space_preparation.py +++ b/ai4rag/components/optimization/search_space_preparation.py @@ -12,6 +12,7 @@ from ogx_client import OgxClient from ai4rag import handler +from ai4rag.components.optimization.judge_selection import select_judge_model from ai4rag.components.utils.docling_io import load_docling_documents from ai4rag.core.experiment.benchmark_data import BenchmarkData from ai4rag.core.experiment.mps import ModelsPreSelector @@ -114,6 +115,8 @@ def prepare_search_space_report( # pylint: disable=too-many-locals,too-many-arg random_seed: int = _DEFAULT_SEED, chunking_methods: list[str] | None = None, inference_max_threads: int = 10, + evaluator: str = "judge", + judge_model_id: str | None = None, ) -> SearchSpaceReport: """Run model pre-selection and prepare a search-space report. @@ -158,6 +161,11 @@ def prepare_search_space_report( # pylint: disable=too-many-locals,too-many-arg RAG service during benchmark evaluation. Lower values reduce per-request concurrency (useful when each request carries more retrieved context). Defaults to ``10``. + evaluator + Evaluation backend: ``"judge"`` (default) or ``"unitxt"``. + judge_model_id + Optional judge model identifier. When omitted and ``evaluator`` is + ``"judge"``, a judge model is auto-selected during preparation. Returns ------- @@ -175,6 +183,9 @@ def prepare_search_space_report( # pylint: disable=too-many-locals,too-many-arg if metric not in SUPPORTED_METRICS: raise ValueError(f"Metric {metric!r} is not supported. Supported metrics are {list(SUPPORTED_METRICS)}.") + if evaluator not in ("judge", "unitxt"): + raise ValueError(f"Evaluator {evaluator!r} is not supported. Supported evaluators are ['judge', 'unitxt'].") + _validate_model_list(embedding_models, "embedding_models") _validate_model_list(generation_models, "generation_models") _validate_chunking_methods(chunking_methods) @@ -240,6 +251,25 @@ def prepare_search_space_report( # pylint: disable=too-many-locals,too-many-arg verbose_repr["chunking_method"] = chunking_methods _logger.info("Chunking methods constrained to: %s", verbose_repr["chunking_method"]) + evaluation_block: dict[str, str] = {"evaluator": evaluator} + if evaluator == "judge": + ogx_api_key = getattr(ogx_client, "api_key", "") or "" + resolved_judge_id = select_judge_model( + evaluator=evaluator, + judge_model_id=judge_model_id, + generation_models=selected_models["foundation_model"], + embedding_models=selected_models["embedding_model"], + benchmark_data=benchmark_data, + documents=documents, + ogx_base_url=ogx_client.base_url, + ogx_api_key=ogx_api_key, + max_threads=inference_max_threads, + ) + if not resolved_judge_id: + raise ValueError("Failed to resolve judge_model_id for evaluator='judge'.") + evaluation_block["judge_model_id"] = resolved_judge_id + verbose_repr["evaluation"] = evaluation_block + return SearchSpaceReport( search_space=verbose_repr, selected_models=selected_models, diff --git a/ai4rag/core/experiment/experiment.py b/ai4rag/core/experiment/experiment.py index f6f0c5d0..219cef98 100644 --- a/ai4rag/core/experiment/experiment.py +++ b/ai4rag/core/experiment/experiment.py @@ -165,6 +165,7 @@ def __init__( self.n_mps_embedding_models = kwargs.pop("n_mps_embedding_models", ModelsPreSelector.DEFAULT_N_EMBEDDING_MODELS) self.known_observations: list[dict] | None = kwargs.pop("known_observations", None) self.inference_max_threads: int = kwargs.pop("inference_max_threads", 10) + self.evaluation_config: dict[str, str] | None = kwargs.pop("evaluation_config", None) self.results: ExperimentResults = ExperimentResults() self._exception_handler = ExperimentExceptionHandler(self.event_handler) @@ -203,14 +204,19 @@ def optimization_metric(self) -> str: @optimization_metric.setter def optimization_metric(self, val: str) -> None: """Validate and set optimization metrics""" - if val not in MetricType: + if val not in MetricType and not self._is_custom_evaluator_metric(val): raise RAGExperimentError( - f"Provided optimization metric: '{val}' is not supported. " - f"Available metrics: ['answer_correctness', 'faithfulness', 'context_correctness']." + f"Provided optimization metric: '{val}' is not supported. " f"Available metrics: {list(MetricType)}." ) self._optimization_metric = val + def _is_custom_evaluator_metric(self, metric_name: str) -> bool: + """Check if a metric is supported by a custom evaluator.""" + if hasattr(self, "evaluator") and hasattr(self.evaluator, "get_supported_metrics"): + return metric_name in self.evaluator.get_supported_metrics() + return False + @property def benchmark_data(self) -> BenchmarkData: """Get benchmark data.""" @@ -644,6 +650,8 @@ def _stream_finished_pattern( }, "iteration": len(self.results) + n_known, } + if self.evaluation_config: + payload["evaluation"] = self.evaluation_config self.event_handler.on_pattern_creation( payload=payload, diff --git a/ai4rag/evaluator/__init__.py b/ai4rag/evaluator/__init__.py index f1769d02..d0937990 100644 --- a/ai4rag/evaluator/__init__.py +++ b/ai4rag/evaluator/__init__.py @@ -2,5 +2,15 @@ # Copyright IBM Corp. 2025-2026 # SPDX-License-Identifier: Apache-2.0 # ----------------------------------------------------------------------------- +from ai4rag import logger from ai4rag.evaluator.base_evaluator import BaseEvaluator from ai4rag.evaluator.unitxt_evaluator import UnitxtEvaluator + +try: + from ai4rag.evaluator.llmaj_evaluator import LLMaJConfig, LLMaJEvaluator +except ImportError as exc: + logger.info( + "LLM-as-a-Judge evaluator is unavailable (%s). " + "Install optional dependencies with: pip install ai4rag[llm-judge]", + exc, + ) diff --git a/ai4rag/evaluator/base_evaluator.py b/ai4rag/evaluator/base_evaluator.py index 8487d6fc..c95a5409 100644 --- a/ai4rag/evaluator/base_evaluator.py +++ b/ai4rag/evaluator/base_evaluator.py @@ -63,15 +63,11 @@ def to_dict(self) -> dict[str, Any]: class MetricType(metaclass=ConstantMeta): """ - Holder for metric names. + Holder for metric names used inpip install -e ".[dev]" + evaluation. - Attributes - ---------- - ANSWER_CORRECTNESS : str, default="answer_correctness" - - FAITHFULNESS : str, default="faithfulness" - - CONTEXT_CORRECTNESS : str, default="context_correctness" + Uses :class:`~ai4rag.utils.constants.ConstantMeta` so values can be + iterated and membership-tested without defining a standard ``Enum``. """ ANSWER_CORRECTNESS = "answer_correctness" diff --git a/ai4rag/evaluator/llmaj_evaluator.py b/ai4rag/evaluator/llmaj_evaluator.py new file mode 100644 index 00000000..3c470b5e --- /dev/null +++ b/ai4rag/evaluator/llmaj_evaluator.py @@ -0,0 +1,207 @@ +# ----------------------------------------------------------------------------- +# Copyright IBM Corp. 2026 +# SPDX-License-Identifier: Apache-2.0 +# ----------------------------------------------------------------------------- +import json +import re +from dataclasses import dataclass +from typing import Sequence + +import numpy as np + +from ai4rag.evaluator.base_evaluator import BaseEvaluator, EvaluationData, MetricType +from ai4rag.evaluator.score_utils import compute_confidence_interval, enrich_with_overall_score + +try: + from openai import OpenAI, OpenAIError +except ImportError as exc: + raise ImportError( + "openai package is required for LLM-as-a-Judge evaluation. " + "Install with: pip install ai4rag[llm-judge]" + ) from exc + + +@dataclass +class LLMaJConfig: + """ + Configuration for the LLM-as-a-Judge evaluator. + + Parameters + ---------- + base_url : str + Base URL of the OpenAI-compatible API endpoint. + + api_key : str + API key for authentication. + + model : str + Model name as reported by the endpoint (e.g. ``"llama-31-8b-instruct"``). + + temperature : float + Temperature for the judge model. + """ + + base_url: str = "https://api.openai.com/v1" + api_key: str = "" + model: str = "gpt-4o-mini" + temperature: float = 0.0 + + +JUDGE_PROMPT_TEMPLATE = """\ +You are an impartial judge evaluating the quality of an AI assistant's response. + +## Task +{guidelines} + +## Context +Question: {question} +Retrieved context: {context} +Ground truth answer: {ground_truth} + +## Response to evaluate +{answer} + +## Instructions +Respond with ONLY a JSON object (no markdown, no extra text): +{{"score": , "rationale": ""}} + +Where: +- 1 = completely fails the criterion +- 2 = mostly fails with some relevant elements +- 3 = partially meets the criterion +- 4 = mostly meets with minor gaps +- 5 = fully meets the criterion +""" + + +class LLMaJEvaluator(BaseEvaluator): + """ + Evaluator that scores RAG responses with an LLM judge via an OpenAI-compatible API. + + All scores are normalized from the judge scale (1-5) to [0.0, 1.0]. + """ + + METRIC_GUIDELINES = { + MetricType.ANSWER_CORRECTNESS: ( + "Evaluate how factually correct the response is compared to the ground truth. " + "A correct answer must contain the same key facts as the ground truth." + ), + MetricType.FAITHFULNESS: ( + "Evaluate whether the response is grounded in the provided context. " + "The answer should only contain claims supported by the context, " + "without hallucinating information." + ), + MetricType.CONTEXT_CORRECTNESS: ( + "Evaluate how relevant the retrieved context is for answering the question. " + "Good context should contain the information necessary to answer the question." + ), + } + + def __init__(self, config: LLMaJConfig): + self.config = config + self._client = OpenAI(base_url=config.base_url, api_key=config.api_key) + + def evaluate_metrics( + self, + evaluation_data: list[EvaluationData], + metrics: Sequence[str], + ) -> dict: + """Evaluate responses with the configured judge model.""" + scores: dict[str, dict[str, float | None]] = {} + question_scores: dict[str, dict[str, float | None]] = {} + question_ids = [ed.question_id or str(i) for i, ed in enumerate(evaluation_data)] + + for metric_name in metrics: + guidelines = self.METRIC_GUIDELINES.get(metric_name) + if guidelines is None: + continue + + row_scores: list[float] = [] + question_scores[metric_name] = {} + for i, ed in enumerate(evaluation_data): + qid = question_ids[i] + normalized = self._judge_row(ed, guidelines) + question_scores[metric_name][qid] = round(normalized, 4) if normalized is not None else None + if normalized is not None: + row_scores.append(normalized) + + ci_low, ci_high = compute_confidence_interval(row_scores) + scores[metric_name] = { + "mean": round(float(np.mean(row_scores)), 4) if row_scores else None, + "ci_low": ci_low, + "ci_high": ci_high, + } + + return enrich_with_overall_score({"scores": scores, "question_scores": question_scores}) + + def _judge_row(self, evaluation_data: EvaluationData, guidelines: str) -> float | None: + """Score a single row with the judge model.""" + contexts_str = "\n\n".join(evaluation_data.contexts) if evaluation_data.contexts else "" + ground_truths_str = "\n".join(evaluation_data.ground_truths) if evaluation_data.ground_truths else "" + prompt = JUDGE_PROMPT_TEMPLATE.format( + guidelines=guidelines, + question=evaluation_data.question or "", + context=contexts_str, + ground_truth=ground_truths_str, + answer=evaluation_data.answer or "", + ) + try: + response = self._client.chat.completions.create( + model=self.config.model, + messages=[{"role": "user", "content": prompt}], + temperature=self.config.temperature, + max_tokens=256, + ) + content = response.choices[0].message.content.strip() + return _normalize_score(_parse_score(content)) + except (OpenAIError, ValueError, KeyError, AttributeError): + return None + + def get_supported_metrics(self) -> list[str]: + """Return metric names supported by this evaluator.""" + return list(self.METRIC_GUIDELINES.keys()) + + +def _extract_json(content: str) -> dict | None: + """Extract a JSON object from LLM output that may contain markdown fences or extra text.""" + try: + return json.loads(content) + except (json.JSONDecodeError, ValueError): + pass + + fence_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", content, re.DOTALL) + if fence_match: + try: + return json.loads(fence_match.group(1)) + except (json.JSONDecodeError, ValueError): + pass + + brace_match = re.search(r"\{[^{}]*\}", content) + if brace_match: + try: + return json.loads(brace_match.group(0)) + except (json.JSONDecodeError, ValueError): + pass + + return None + + +def _parse_score(content: str) -> int | None: + """Parse a score (1-5) from the judge's JSON response.""" + data = _extract_json(content) + if data is None: + return None + try: + score = int(data["score"]) + if 1 <= score <= 5: + return score + except (KeyError, ValueError, TypeError): + pass + return None + + +def _normalize_score(score: int | None) -> float | None: + """Normalize a score from 1-5 to [0.0, 1.0].""" + if score is None: + return None + return (score - 1) / 4 diff --git a/ai4rag/evaluator/score_utils.py b/ai4rag/evaluator/score_utils.py new file mode 100644 index 00000000..fdc431d6 --- /dev/null +++ b/ai4rag/evaluator/score_utils.py @@ -0,0 +1,68 @@ +# ----------------------------------------------------------------------------- +# Copyright IBM Corp. 2026 +# SPDX-License-Identifier: Apache-2.0 +# ----------------------------------------------------------------------------- +from typing import Any + +import numpy as np + +from ai4rag.evaluator.base_evaluator import MetricType + +STANDARD_TRIO = ( + MetricType.FAITHFULNESS, + MetricType.ANSWER_CORRECTNESS, + MetricType.CONTEXT_CORRECTNESS, +) + + +def compute_confidence_interval( + scores: list[float], confidence: float = 0.95, n_bootstrap: int = 1000 +) -> tuple[float | None, float | None]: + """Compute bootstrap confidence interval for the mean score.""" + if len(scores) < 2: + return None, None + + rng = np.random.default_rng(seed=42) + bootstrap_means = [float(np.mean(rng.choice(scores, size=len(scores), replace=True))) for _ in range(n_bootstrap)] + + alpha = (1 - confidence) / 2 + return ( + round(float(np.percentile(bootstrap_means, alpha * 100)), 4), + round(float(np.percentile(bootstrap_means, (1 - alpha) * 100)), 4), + ) + + +def enrich_with_overall_score(result: dict[str, Any]) -> dict[str, Any]: + """Add derived ``overall_score`` to pattern-level and per-question scores.""" + scores = result.get("scores") or {} + question_scores = result.get("question_scores") or {} + + question_ids: set[str] = set() + for metric in STANDARD_TRIO: + question_ids.update((question_scores.get(metric) or {}).keys()) + + per_question_overall: list[float] = [] + overall_by_question: dict[str, float | None] = {} + for qid in question_ids: + values = [ + question_scores[metric][qid] + for metric in STANDARD_TRIO + if metric in question_scores and question_scores[metric].get(qid) is not None + ] + if values: + mean_val = round(float(np.mean(values)), 4) + overall_by_question[qid] = mean_val + per_question_overall.append(mean_val) + else: + overall_by_question[qid] = None + + question_scores["overall_score"] = overall_by_question + + ci_low, ci_high = compute_confidence_interval(per_question_overall) + scores["overall_score"] = { + "mean": round(float(np.mean(per_question_overall)), 4) if per_question_overall else None, + "ci_low": ci_low, + "ci_high": ci_high, + } + + return {"scores": scores, "question_scores": question_scores} diff --git a/ai4rag/evaluator/unitxt_evaluator.py b/ai4rag/evaluator/unitxt_evaluator.py index 81fedcea..24eab266 100644 --- a/ai4rag/evaluator/unitxt_evaluator.py +++ b/ai4rag/evaluator/unitxt_evaluator.py @@ -13,6 +13,7 @@ EvaluationData, MetricType, ) +from ai4rag.evaluator.score_utils import enrich_with_overall_score class UnitxtEvaluator(BaseEvaluator): @@ -55,7 +56,7 @@ def evaluate_metrics( returned_ci = self._handle_ci_calculations(ci_table=ci_table) question_scores = self._handle_questions_scores(scores_df=scores_df) - return {"scores": returned_ci, "question_scores": question_scores} + return enrich_with_overall_score({"scores": returned_ci, "question_scores": question_scores}) except Exception as exc: raise EvaluationError(exc) from exc diff --git a/docs/design/llm-as-judge-design.md b/docs/design/llm-as-judge-design.md new file mode 100644 index 00000000..9f3c9c0c --- /dev/null +++ b/docs/design/llm-as-judge-design.md @@ -0,0 +1,178 @@ +# Design: LLM-as-a-Judge Optimization Metric + +## 1. Motivation + +The current ai4rag evaluation stack uses **unitxt** with algorithmic (non-LLM) metrics: +`answer_correctness`, `faithfulness`, and `context_correctness`. These are fast and deterministic +but limited in capturing nuanced quality dimensions like helpfulness, coherence, or domain-specific +correctness that only an LLM judge can assess. + +**Goal:** Allow users to use LLM-as-a-Judge as optimization metrics in ai4rag's +HPO loop, by swapping the evaluator. The same metric names (`answer_correctness`, `faithfulness`, +etc.) are used — the evaluator determines the evaluation method. + +--- + +## 2. Architecture + +``` +BaseEvaluator (ABC) + └── evaluate_metrics(evaluation_data, metrics) -> dict + +UnitxtEvaluator(BaseEvaluator) # algorithmic metrics +MlflowLLMJudgeEvaluator(BaseEvaluator) # LLM-as-a-Judge metrics + +MetricType (ConstantMeta) + └── ANSWER_CORRECTNESS, FAITHFULNESS, CONTEXT_CORRECTNESS, ANSWER_RELEVANCE +``` + +The evaluator is a constructor parameter of `AI4RAGExperiment`. Users swap between unitxt and +LLM-as-a-Judge by passing a different evaluator — no changes to the optimizer or experiment +orchestrator are needed. Both evaluators use the same metric names and return the same dict format. + +All scores are normalized to **[0.0, 1.0]**. + +--- + +## 3. New Metric Type + +One new metric added to `MetricType`: + +```python +class MetricType(metaclass=ConstantMeta): + ANSWER_CORRECTNESS = "answer_correctness" + FAITHFULNESS = "faithfulness" + CONTEXT_CORRECTNESS = "context_correctness" + ANSWER_RELEVANCE = "answer_relevance" # NEW +``` + +No `LLM_` prefix — the metric name describes *what* is measured, not *how*. + +--- + +## 4. MlflowLLMJudgeEvaluator + +New file: `ai4rag/evaluator/mlflow_llm_judge_evaluator.py` + +### 4.1 Configuration + +```python +@dataclass +class LLMJudgeConfig: + base_url: str = "https://api.openai.com/v1" + api_key: str = "" + model: str = "gpt-4o-mini" + temperature: float = 0.0 + custom_metrics: list[CustomMetricDefinition] = field(default_factory=list) +``` + +The evaluator uses **MLflow's evaluation framework** (`mlflow.genai.evaluate()`) with custom +`@scorer` functions. The scorers call the judge LLM via the **OpenAI Python client** with a +configurable `base_url`, enabling any OpenAI-compatible endpoint (vLLM, TGI, etc.). This gives +full MLflow tracking/logging integration while working around MLflow's internal gateway routing +which does not respect custom base URLs for `openai:/` URIs. + +### 4.2 Built-in Metric Prompts + +The evaluator ships with grading prompts for all four `MetricType` values: +- `answer_correctness` — factual correctness vs ground truth +- `faithfulness` — grounding in retrieved context +- `context_correctness` — relevance of retrieved documents +- `answer_relevance` — relevance and helpfulness to the question + +Each uses a 1-5 scale that is normalized to [0.0, 1.0] via `(score - 1) / 4`. + +### 4.3 Evaluation Flow + +``` +1. Convert list[EvaluationData] → MLflow eval data format + (inputs: {question, context}, outputs: answer, expectations: {expected_response}) +2. Build MLflow @scorer functions for each requested metric + - Each scorer calls the judge LLM via OpenAI client + - Parses JSON {"score": 1-5, "rationale": "..."} response + - Normalizes to [0.0, 1.0] and returns mlflow.entities.Feedback +3. Call mlflow.genai.evaluate(data=eval_data, scorers=scorers) +4. Extract per-row scores from eval_results table +5. Compute mean + bootstrap confidence intervals (seed=42, n=1000) +6. Return {"scores": {metric: {mean, ci_low, ci_high}}, + "question_scores": {metric: {q_id: score}}} +``` + +### 4.4 Custom LLM Judge Metrics + +```python +config = LLMJudgeConfig( + base_url="https://my-llm-endpoint.example.com/v1", + api_key="my-token", + model="llama-31-8b-instruct", + custom_metrics=[ + CustomMetricDefinition( + name="medical_accuracy", + guidelines="Evaluate whether the answer contains medically accurate information.", + ) + ] +) +``` + +Custom metric names are accepted as `optimization_metric` when the evaluator supports them. + +--- + +## 5. Integration Points + +| File | Change | +|------|--------| +| `evaluator/base_evaluator.py` | Add `ANSWER_RELEVANCE` to `MetricType` | +| `evaluator/mlflow_llm_judge_evaluator.py` | **New file** | +| `evaluator/__init__.py` | Conditional export of new classes | +| `core/experiment/experiment.py` | Accept custom evaluator metric names in validation | +| `pyproject.toml` | Add `mlflow` + `openai` as optional dependencies (`ai4rag[llm-judge]`) | + +--- + +## 6. Dependency Management + +```toml +[project.optional-dependencies] +llm-judge = ["mlflow>=3.0.0", "openai>=1.0.0"] +``` + +The evaluator raises a clear `ImportError` if the packages are not installed. + +--- + +## 7. Usage Example + +```python +from ai4rag.evaluator.mlflow_llm_judge_evaluator import MlflowLLMJudgeEvaluator, LLMJudgeConfig +from ai4rag.evaluator.base_evaluator import MetricType + +config = LLMJudgeConfig( + base_url="https://llama-31-8b-instruct.apps.example.com/v1", + api_key="my-token", + model="llama-31-8b-instruct", +) + +experiment = AI4RAGExperiment( + documents=documents, + benchmark_data=benchmark_df, + search_space=search_space, + vector_store_type="chroma", + optimizer_settings=optimizer_settings, + event_handler=event_handler, + evaluator=MlflowLLMJudgeEvaluator(config), + optimization_metric=MetricType.FAITHFULNESS, + metrics=(MetricType.ANSWER_CORRECTNESS, MetricType.FAITHFULNESS), +) + +experiment.search() +``` + +--- + +## References + +- [MLflow LLM Evaluation docs](https://mlflow.org/docs/latest/genai/eval-monitor/llm-evaluation/) +- [MLflow Custom Scorers](https://mlflow.org/docs/latest/genai/eval-monitor/scorers/) +- [OpenAI Python Client](https://github.com/openai/openai-python) +- [vLLM OpenAI Compatible Server](https://docs.vllm.ai/en/latest/serving/openai_compatible_server.html) diff --git a/pyproject.toml b/pyproject.toml index 44f64349..1aba08e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,10 +57,15 @@ Documentation = "https://ibm.github.io/ai4rag/" [project.optional-dependencies] +llm-judge = [ + "openai~=1.109.1", +] + dev = [ "ai4rag[test]", "ai4rag[code_check]", "ai4rag[docs]", + "ai4rag[llm-judge]", "beautifulsoup4", # reading data from html "dotenv", "ipykernel", diff --git a/tests/unit/ai4rag/components/optimization/test_judge_selection.py b/tests/unit/ai4rag/components/optimization/test_judge_selection.py new file mode 100644 index 00000000..11b27369 --- /dev/null +++ b/tests/unit/ai4rag/components/optimization/test_judge_selection.py @@ -0,0 +1,11 @@ +# ----------------------------------------------------------------------------- +# Copyright IBM Corp. 2026 +# SPDX-License-Identifier: Apache-2.0 +# ----------------------------------------------------------------------------- +from ai4rag.components.optimization.judge_selection import calibration_subset_size + + +def test_calibration_subset_size(): + assert calibration_subset_size(5) == 1 + assert calibration_subset_size(100) == 10 + assert calibration_subset_size(500) == 20 diff --git a/tests/unit/ai4rag/components/optimization/test_rag_optimization.py b/tests/unit/ai4rag/components/optimization/test_rag_optimization.py index a8226482..d89d031b 100644 --- a/tests/unit/ai4rag/components/optimization/test_rag_optimization.py +++ b/tests/unit/ai4rag/components/optimization/test_rag_optimization.py @@ -261,3 +261,70 @@ def test_max_threads_is_accepted(self, mock_ogx_client): test_data_key="bench.json", max_threads=4, ) + + +# --------------------------------------------------------------------------- +# run_rag_optimization -- evaluator / judge_model_id parameters +# --------------------------------------------------------------------------- + + +class TestRunRagOptimizationEvaluator: + """Tests for ADR evaluator parameters on run_rag_optimization.""" + + def test_evaluator_defaults_to_judge(self): + import inspect + + sig = inspect.signature(run_rag_optimization) + assert sig.parameters["evaluator"].default == "judge" + + def test_judge_requires_model_id(self, mock_ogx_client): + with pytest.raises(ValueError, match="judge_model_id is required"): + run_rag_optimization( + extracted_text_path="dummy", + test_data_path="dummy.json", + search_space_report_path="dummy.json", + output_dir="out", + ogx_client=mock_ogx_client, + vector_io_provider_id="provider-1", + test_data_key="bench.json", + evaluator="judge", + ) + + def test_unitxt_evaluator_does_not_require_judge_model(self, mock_ogx_client): + with pytest.raises(Exception, match="(?!judge_model_id is required)"): + run_rag_optimization( + extracted_text_path="dummy", + test_data_path="dummy.json", + search_space_report_path="dummy.json", + output_dir="out", + ogx_client=mock_ogx_client, + vector_io_provider_id="provider-1", + test_data_key="bench.json", + evaluator="unitxt", + ) + + def test_invalid_evaluator_rejected(self): + with pytest.raises(ValueError, match="is not supported"): + run_rag_optimization( + extracted_text_path="dummy", + test_data_path="dummy.json", + search_space_report_path="dummy.json", + output_dir="out", + ogx_client=MagicMock(), + vector_io_provider_id="provider-1", + test_data_key="bench.json", + evaluator="unknown", + ) + + def test_judge_model_id_is_accepted(self, mock_ogx_client): + with pytest.raises(ValueError, match="non-empty string"): + run_rag_optimization( + extracted_text_path="dummy", + test_data_path="dummy.json", + search_space_report_path="dummy.json", + output_dir="out", + ogx_client=mock_ogx_client, + vector_io_provider_id="", + test_data_key="bench.json", + judge_model_id="some-model", + ) diff --git a/tests/unit/ai4rag/evaluator/test_llmaj_evaluator.py b/tests/unit/ai4rag/evaluator/test_llmaj_evaluator.py new file mode 100644 index 00000000..0bb9604b --- /dev/null +++ b/tests/unit/ai4rag/evaluator/test_llmaj_evaluator.py @@ -0,0 +1,98 @@ +# ----------------------------------------------------------------------------- +# Copyright IBM Corp. 2026 +# SPDX-License-Identifier: Apache-2.0 +# ----------------------------------------------------------------------------- +import json +from unittest.mock import MagicMock, patch + +import pytest + +from ai4rag.evaluator.base_evaluator import BaseEvaluator, EvaluationData, MetricType +from ai4rag.evaluator.llmaj_evaluator import ( + LLMaJConfig, + LLMaJEvaluator, + _extract_json, + _normalize_score, + _parse_score, +) + + +@pytest.fixture +def sample_evaluation_data() -> list[EvaluationData]: + return [ + EvaluationData( + question="What is Python?", + answer="Python is a programming language.", + contexts=["Python is a high-level programming language."], + context_ids=["doc1"], + ground_truths=["Python is a programming language."], + ground_truths_context_ids=["doc1"], + question_id="q1", + ), + EvaluationData( + question="What is AI?", + answer="AI is Artificial Intelligence.", + contexts=["AI stands for Artificial Intelligence."], + context_ids=["doc2"], + ground_truths=["AI is Artificial Intelligence."], + ground_truths_context_ids=["doc2"], + question_id="q2", + ), + ] + + +def _make_chat_response(score: int) -> MagicMock: + resp = MagicMock() + resp.choices = [MagicMock()] + resp.choices[0].message.content = json.dumps({"score": score, "rationale": "OK"}) + return resp + + +class TestLLMaJConfig: + def test_default_config(self): + config = LLMaJConfig() + assert config.base_url == "https://api.openai.com/v1" + assert config.model == "gpt-4o-mini" + assert config.temperature == 0.0 + + +class TestLLMaJEvaluator: + @patch("ai4rag.evaluator.llmaj_evaluator.OpenAI") + def test_supported_metrics(self, _mock_openai): + evaluator = LLMaJEvaluator(LLMaJConfig()) + supported = evaluator.get_supported_metrics() + assert MetricType.FAITHFULNESS in supported + assert MetricType.ANSWER_CORRECTNESS in supported + assert MetricType.CONTEXT_CORRECTNESS in supported + + @patch("ai4rag.evaluator.llmaj_evaluator.OpenAI") + def test_evaluate_metrics(self, mock_openai_cls, sample_evaluation_data): + client = MagicMock() + mock_openai_cls.return_value = client + client.chat.completions.create.side_effect = [ + _make_chat_response(5), + _make_chat_response(4), + ] + + evaluator = LLMaJEvaluator(LLMaJConfig(model="judge-model")) + result = evaluator.evaluate_metrics(sample_evaluation_data, [MetricType.FAITHFULNESS]) + + assert result["scores"][MetricType.FAITHFULNESS]["mean"] == 0.875 + assert result["question_scores"][MetricType.FAITHFULNESS]["q1"] == 1.0 + assert result["question_scores"][MetricType.FAITHFULNESS]["q2"] == 0.75 + + @patch("ai4rag.evaluator.llmaj_evaluator.OpenAI") + def test_is_base_evaluator_subclass(self, _mock_openai): + assert issubclass(LLMaJEvaluator, BaseEvaluator) + + +class TestParsingHelpers: + def test_extract_json_plain(self): + assert _extract_json('{"score": 4}') == {"score": 4} + + def test_parse_score_valid(self): + assert _parse_score('{"score": 3}') == 3 + + def test_normalize_score(self): + assert _normalize_score(1) == 0.0 + assert _normalize_score(5) == 1.0 diff --git a/tests/unit/ai4rag/evaluator/test_score_utils.py b/tests/unit/ai4rag/evaluator/test_score_utils.py new file mode 100644 index 00000000..79b04f01 --- /dev/null +++ b/tests/unit/ai4rag/evaluator/test_score_utils.py @@ -0,0 +1,28 @@ +# ----------------------------------------------------------------------------- +# Copyright IBM Corp. 2026 +# SPDX-License-Identifier: Apache-2.0 +# ----------------------------------------------------------------------------- +import numpy as np + +from ai4rag.evaluator.score_utils import enrich_with_overall_score + + +def test_enrich_with_overall_score(): + result = enrich_with_overall_score( + { + "scores": { + "faithfulness": {"mean": 0.8, "ci_low": 0.7, "ci_high": 0.9}, + "answer_correctness": {"mean": 0.6, "ci_low": 0.5, "ci_high": 0.7}, + "context_correctness": {"mean": 0.4, "ci_low": 0.3, "ci_high": 0.5}, + }, + "question_scores": { + "faithfulness": {"q1": 0.8, "q2": 1.0}, + "answer_correctness": {"q1": 0.6, "q2": 0.8}, + "context_correctness": {"q1": 0.4, "q2": 0.6}, + }, + } + ) + + assert result["scores"]["overall_score"]["mean"] == round(float(np.mean([0.6, 0.8])), 4) + assert result["question_scores"]["overall_score"]["q1"] == 0.6 + assert result["question_scores"]["overall_score"]["q2"] == 0.8 diff --git a/tests/unit/ai4rag/evaluator/test_unitxt_evaluator.py b/tests/unit/ai4rag/evaluator/test_unitxt_evaluator.py index 1296287f..8fc09090 100644 --- a/tests/unit/ai4rag/evaluator/test_unitxt_evaluator.py +++ b/tests/unit/ai4rag/evaluator/test_unitxt_evaluator.py @@ -429,8 +429,10 @@ def test_evaluate_metrics_with_all_metrics(self, mocker, sample_evaluation_data_ [MetricType.ANSWER_CORRECTNESS, MetricType.FAITHFULNESS, MetricType.CONTEXT_CORRECTNESS], ) - assert len(result["scores"]) == 3 - assert len(result["question_scores"]) == 3 + assert len(result["scores"]) == 4 + assert "overall_score" in result["scores"] + assert "overall_score" in result["question_scores"] + assert len(result["question_scores"]) == 4 class TestUnitxtEvaluatorIntegration: diff --git a/uv.lock b/uv.lock index 8a27ef1c..28ac81e2 100644 --- a/uv.lock +++ b/uv.lock @@ -63,6 +63,7 @@ dev = [ { name = "mkdocs-minify-plugin" }, { name = "mkdocstrings", extra = ["python"] }, { name = "nbformat" }, + { name = "openai" }, { name = "psutil" }, { name = "pylint" }, { name = "pytest" }, @@ -76,6 +77,9 @@ docs = [ { name = "mkdocs-minify-plugin" }, { name = "mkdocstrings", extra = ["python"] }, ] +llm-judge = [ + { name = "openai" }, +] test = [ { name = "nbformat" }, { name = "psutil" }, @@ -88,6 +92,7 @@ test = [ requires-dist = [ { name = "ai4rag", extras = ["code-check"], marker = "extra == 'dev'" }, { name = "ai4rag", extras = ["docs"], marker = "extra == 'dev'" }, + { name = "ai4rag", extras = ["llm-judge"], marker = "extra == 'dev'" }, { name = "ai4rag", extras = ["test"], marker = "extra == 'dev'" }, { name = "beautifulsoup4", marker = "extra == 'dev'" }, { name = "black", marker = "extra == 'code-check'" }, @@ -108,6 +113,7 @@ requires-dist = [ { name = "multiprocess", specifier = ">=0.70" }, { name = "nbformat", marker = "extra == 'test'" }, { name = "ogx-client", specifier = "~=1.1.0" }, + { name = "openai", marker = "extra == 'llm-judge'", specifier = "~=1.109.1" }, { name = "pandas", specifier = "==2.2.*" }, { name = "psutil", marker = "extra == 'test'" }, { name = "pydantic", specifier = "==2.11.*" }, @@ -119,7 +125,7 @@ requires-dist = [ { name = "scikit-learn", specifier = "==1.8.*" }, { name = "unitxt", specifier = "~=1.26.1" }, ] -provides-extras = ["dev", "test", "code-check", "docs"] +provides-extras = ["llm-judge", "dev", "test", "code-check", "docs"] [[package]] name = "aiohappyeyeballs" @@ -383,30 +389,30 @@ wheels = [ [[package]] name = "boto3" -version = "1.43.29" +version = "1.43.41" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/85/a6/9c02ff00d08ea87908934351d244e35bb6fb5cbc169e1a14fc5bd80d124b/boto3-1.43.29.tar.gz", hash = "sha256:354006c512cdb87ef8214a095f2ade961c8145734475cd7a7e6b39260ff5494a", size = 113198, upload-time = "2026-06-12T19:32:23.442Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/5b/e91af651bf4e902f86b9e2f8bc09ca20dbd1ad3c1e21b70bf34651cf3cee/boto3-1.43.41.tar.gz", hash = "sha256:0f56811f13677bfb4542daa0cce8532c95d9afd27b4ba7b681af36a0568624ad", size = 112677, upload-time = "2026-07-06T19:39:38.788Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/5c/f12a9978526c7068c873ccf9788161fc6af338c6a025f1354a46134a6e46/boto3-1.43.29-py3-none-any.whl", hash = "sha256:77c27ada27cdbf619a3bbc41fa9e991caef818d3a2988cf92ea722e107d90108", size = 140537, upload-time = "2026-06-12T19:32:21.682Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4a/babb2de16f6ff0688697d2eac7d95566151c255f6b08f547306f57dabfc2/boto3-1.43.41-py3-none-any.whl", hash = "sha256:f48f862d2720ea9203ed2d842d436b8eb2d459ea31654a7ad7c0756fdf36c6b2", size = 140029, upload-time = "2026-07-06T19:39:37.125Z" }, ] [[package]] name = "botocore" -version = "1.43.29" +version = "1.43.41" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6f/54/df99c5ca5c9ef275e34b87e177782e3ca054fc35f1f462c40fe180936c81/botocore-1.43.29.tar.gz", hash = "sha256:dce39d33b707aa162aa3820975f99d7f8f746d46576169fb42ce4f2b3b56b261", size = 15512384, upload-time = "2026-06-12T19:32:13.754Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/a1/48a0f38b0cac8196764607c0bca7e3ca50d3cffc825087b743d3635413f2/botocore-1.43.41.tar.gz", hash = "sha256:27627d79af0df7dcb7ecf78d8d3d1310da09a5e9460be30bf759f1c2ed095ee8", size = 15647567, upload-time = "2026-07-06T19:39:27.119Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/b1/aa410c22355f8f6c4ac2433db6a1c557dd959acf2953ccae4bfc37488119/botocore-1.43.29-py3-none-any.whl", hash = "sha256:5d62f2a03ed279a50207ca2824e009313df15f082b6bb591a095a4f04c7faef3", size = 15194135, upload-time = "2026-06-12T19:32:09.463Z" }, + { url = "https://files.pythonhosted.org/packages/61/4f/19e0b97ce1801c66a4d1d35117f36240aa20f864338f093fccc23873a231/botocore-1.43.41-py3-none-any.whl", hash = "sha256:0cc6e79b30a2a98374f16a31cd9c7a9106a51b60650bd8c34cc8223f58ae6b8d", size = 15331199, upload-time = "2026-07-06T19:39:23.694Z" }, ] [[package]] @@ -1466,6 +1472,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jiter" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/b5/55f06bb281d92fb3cc86d14e1def2bd908bb77693183e7cb1f5a3c388b0c/jiter-0.15.0.tar.gz", hash = "sha256:4251acc80e2b7c9b7b8823456ea0fceeb0734dac2df7636d3c711b38476b5a76", size = 166640, upload-time = "2026-05-19T10:09:48.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/53/4f6bddbcde3c71e56d0aa1337ec95950f3d27dd4153e25aadf0feac71751/jiter-0.15.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0e90a1c315a0226ec822d973817967f9223b7701546c8c2a7913e7ab0926294d", size = 308793, upload-time = "2026-05-19T10:07:35.25Z" }, + { url = "https://files.pythonhosted.org/packages/01/84/c01099b59a285a1ebba64ae93f62bfa036675340fd1b0045ae65890a0442/jiter-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8c9004af7c8d67cce7f1aae1026fb55607f4aa600710d08ede3a3ce4aeefe7e0", size = 309570, upload-time = "2026-05-19T10:07:36.919Z" }, + { url = "https://files.pythonhosted.org/packages/58/64/8fb7f9d45bb98190355454cd04dad8d8f27223d6bd52f83af07f637168a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c210f8b35dc6f30aafd4b4365ca89b9d1189f21ab49b8e68fa6322a847aef138", size = 336783, upload-time = "2026-05-19T10:07:38.694Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b6/f5739011d009b3a30f6a53c5240979030ba29ae46a8c67e3a15759f7c37d/jiter-0.15.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f30bae8bc1c2d613e28e5af3e8cceb09b742f1c8a8a5f839fb67afaffc03b61", size = 363555, upload-time = "2026-05-19T10:07:40.832Z" }, + { url = "https://files.pythonhosted.org/packages/e5/12/98a9d9f766665e8a3b6252454e17cb0c464606a28cf2fa09399b003345fa/jiter-0.15.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c60e71b6d10cfc284c9bf36bd885e8d44c46f688ce50aa91b5edd90181dea687", size = 452255, upload-time = "2026-05-19T10:07:42.62Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d5/60f972840f79c5e7544fce567c56f1e4e50468f996baba3e78d823dd62a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ab068bce62a45aa3e7367eceaffb5dde60b7eb853be8dece45132e3d0ff4879", size = 373559, upload-time = "2026-05-19T10:07:44.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/cf/d46ef1234ba335aabc2f013210db8e0821a22f5e644a2e9449df199ecc23/jiter-0.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa248c9eb220197d363f688818dac2fd4b2f0cd7d843ca7105d652034823427d", size = 346055, upload-time = "2026-05-19T10:07:46.005Z" }, + { url = "https://files.pythonhosted.org/packages/f0/63/4d2749d8d54d230bad9b3a6b0d00cc28c6ff6b2fdffc26a8ccf76cc5a974/jiter-0.15.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2a77aadd57cac1682e4401a72724d2796d89a4ba129b1a5812aa94ee480826eb", size = 351406, upload-time = "2026-05-19T10:07:47.855Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b9/9965b990035d8773328e0a8c8b457a87bf2b19f6c4126d9d99296be5d16a/jiter-0.15.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2ae901f3a55bfafdde31d289590fa25e3245735a2b1e8c7cc15871710a002871", size = 389357, upload-time = "2026-05-19T10:07:49.665Z" }, + { url = "https://files.pythonhosted.org/packages/2d/55/9ddf903deda1413e87fed792f416b7123daee5b8efbad6a202a7421c36a5/jiter-0.15.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f0b271b462769543716f92d3a4f90527df6ef5ed05ee95ec4137f513e21e1b77", size = 517263, upload-time = "2026-05-19T10:07:51.537Z" }, + { url = "https://files.pythonhosted.org/packages/e8/76/a0c40ad064d3a20a4fde231e35d56e9a01ce82164278180e82d5daf85469/jiter-0.15.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2fb6a5d26af81fc0f00f9360a891e05cf755e149bba391c4d563adc54812973d", size = 548646, upload-time = "2026-05-19T10:07:53.196Z" }, + { url = "https://files.pythonhosted.org/packages/23/4f/eca9b954942916ba2f453891b8593ab444cd872396fe66a3936616f236f3/jiter-0.15.0-cp312-cp312-win32.whl", hash = "sha256:c2f6bb8b5216ab9e7873bc08b5d7bef2b8abbb578a3069bf1cd14a45d71d771d", size = 206427, upload-time = "2026-05-19T10:07:55.307Z" }, + { url = "https://files.pythonhosted.org/packages/95/bf/8ead82a87495149542748e828d153fd232a512a22c83b02c4815c1a9c7d8/jiter-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:40b2c7e92c44a84d748d21706c68dc6ff8161d80b59c99d774721a0d2317d7c7", size = 197300, upload-time = "2026-05-19T10:07:56.651Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e4/9b8a78fb2d894471bc344e37f1949bdd784bd914d031dba0ba3a40c71dd7/jiter-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:cc0bc345cf2df9d1c00ac443f50d543c1ccfa8b0422cb85b1ab70d681c0b255b", size = 192702, upload-time = "2026-05-19T10:07:58.307Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f4/f708c900ecee41b2025ef8413d5351e5649eb2125c506f6720cc69b06f5c/jiter-0.15.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1c11465f97e2abf45a014b83b730222f8f1c5335e802c7055a67d50de6f1f4e3", size = 307829, upload-time = "2026-05-19T10:07:59.704Z" }, + { url = "https://files.pythonhosted.org/packages/86/59/db537c0949e83668c38481d426b9f2fd5ab758c4ee53a811dd0a510626a0/jiter-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e7b1776f0797956c509e123d0952d10d293a9492dea9f288ab9570ec01d1a5", size = 308445, upload-time = "2026-05-19T10:08:01.184Z" }, + { url = "https://files.pythonhosted.org/packages/37/38/ea0e13b18c30ef951da0d47d39e7fa9edb82a93a62990ffbd7cea9b622d4/jiter-0.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:351a341c2105aa430b7047e30f1bf7975f6313b00165d3fc07be2edaf741f279", size = 336181, upload-time = "2026-05-19T10:08:02.688Z" }, + { url = "https://files.pythonhosted.org/packages/58/fc/2303901b16c4ba05865588990a420c0b4156270b44379c20931544a1d962/jiter-0.15.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ab395feec8d249ec4044e228e98a7033f043426a265df439dc3698823f0a4e4", size = 362985, upload-time = "2026-05-19T10:08:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6f/11bace093c52e7d4d26c8e606ccd7ae8c972189622469ec0d9e28161e28b/jiter-0.15.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2a438005b6f22d0273413484d6094d7c2c5d10ec1b3a3bf128e0d1d3ba53258", size = 453292, upload-time = "2026-05-19T10:08:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/22/db/987f2f086ca4d7a6582eb4ccd513f9b26b42d9e4243a087609a3137a8fc7/jiter-0.15.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f18f85e4218d1b40f000f42a92239a7a61a902cd42c65e6c360dbd17dcb20894", size = 373501, upload-time = "2026-05-19T10:08:07.857Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7c/89fbcabb2739b7a5b8dc959a1b6c5761f6484f5fed3486854b3c789bb1de/jiter-0.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1aa62e277fc1cbd80e6deacae6f4d983b41b3d7728e0645c5d741a6149bba45", size = 344683, upload-time = "2026-05-19T10:08:09.431Z" }, + { url = "https://files.pythonhosted.org/packages/30/6f/6cca7692e7dddfec6d8d76c54dc97f2af2a41df4ac0674b999df1f09a5f3/jiter-0.15.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:6550fa135c7deb8ead6af49ed7ff648532ea8334a1447fe34a36315ef79c5c29", size = 350892, upload-time = "2026-05-19T10:08:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/39/14/0338d6190cb8e6d22e677ab1d4eabd4117f67cca70c54cd04b82ff64e068/jiter-0.15.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:066f8f33f18b2419cd8213b2436fa7fbc9c499f315971cfa3ce1f9820c001b1b", size = 388723, upload-time = "2026-05-19T10:08:12.912Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/cc19f4a1bdb6afb09ce6a2f2615aa8d44d994eba0d8e6105ed1af920e736/jiter-0.15.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:75e8a04e91432dde9f1838373cf93d23726c79d3e908d319acf0e796f85592e7", size = 516648, upload-time = "2026-05-19T10:08:14.808Z" }, + { url = "https://files.pythonhosted.org/packages/49/9f/833c541512cd091b63c10c0381973dfe11bc7a503a818c16384417e0c81e/jiter-0.15.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a97261f1fccb8e50ecd2890a96e46efdc3f57c80a197324c6777827231eca712", size = 547382, upload-time = "2026-05-19T10:08:16.927Z" }, + { url = "https://files.pythonhosted.org/packages/d2/11/e7b70e91f90bc4477e8eee9e8a5f7cf3cb41b4525d6394dc98a714eb8f7f/jiter-0.15.0-cp313-cp313-win32.whl", hash = "sha256:c77496cb10bd7549690fbbab3e5ec05857b83e49276f4a9423a766ddd2afcd4c", size = 205845, upload-time = "2026-05-19T10:08:18.401Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/5c20d9ad6f02c493e4023e5d2d09e1c1f15fe2753c9102c544aff068a88e/jiter-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b15741f501469009ae0ae90b7147958a664a7dede40aa7ff174a8a4645f546d0", size = 196842, upload-time = "2026-05-19T10:08:20.131Z" }, + { url = "https://files.pythonhosted.org/packages/6b/11/1eb400ef248e8c925fd883fbe325daf5e42cd1b0d308539dd332bd4f7ffc/jiter-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d6a60072b44c3c2b797a7ddcbcbbf2b34ea3cfd4721580fbfd2a09d9d9b84ba", size = 192212, upload-time = "2026-05-19T10:08:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/8a/60/2fd8d7c79da8acf9b7b277c7616847773779356b92acfc9bb158452174da/jiter-0.15.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ef1fd24d9413f6209e00d3d5a453e67acfe004a25cc6c8e8484faed4311ab9e8", size = 315065, upload-time = "2026-05-19T10:08:23.218Z" }, + { url = "https://files.pythonhosted.org/packages/46/f4/008fb7d65e8ac2abf00811651a661e025c4ba80bbc6f378450384ddd3aed/jiter-0.15.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:144f8e72cb53dab146347b91cceac01f5481237f2b93b4a339a1ee8f8878b67c", size = 339444, upload-time = "2026-05-19T10:08:24.701Z" }, + { url = "https://files.pythonhosted.org/packages/00/55/90b0c7b9c6896c0f2a591dd36d36b71d22e09674bfef178fa03ba3f81499/jiter-0.15.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553fcac2ef2cb990877f9fc0833b8b629a3e6a5670b6b5fd58219b41a653ddc4", size = 347779, upload-time = "2026-05-19T10:08:26.408Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/69666cec5000fd57734c118437394516c749ae8dbeea9fb66d6fef9c4775/jiter-0.15.0-cp313-cp313t-win_amd64.whl", hash = "sha256:774f93f65031856bf14ad9f59bdcab8b8cad501e5ceabd51ba3525f76937a25b", size = 200395, upload-time = "2026-05-19T10:08:28.055Z" }, + { url = "https://files.pythonhosted.org/packages/39/04/a6aa62cd27e8149b0d28df5561f10f6cceaf7935a9ccf3f1c5a05f9a0cd8/jiter-0.15.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f1e1754960f38ec40613a07e5e372df67acb3b890fb383b6fb3de3e49ddbf3c7", size = 190516, upload-time = "2026-05-19T10:08:29.35Z" }, + { url = "https://files.pythonhosted.org/packages/73/38/505941b2b092fd5bbbd60a52a880db1173f1690ae6751bed3af1c9ddcb4e/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:631f13a3d04e97d4e083993b10f4b99530e3a10d953e2eb5e196b7dc7f812ce0", size = 303769, upload-time = "2026-05-19T10:09:42.203Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/a06692b29e77473f286e1ec1f426d3ca44d7b5843be8ad21d7a5f3fcdcc0/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:b6c0ffae686c39bf3737be60793783267628783ea42545632c10b291105aee45", size = 305128, upload-time = "2026-05-19T10:09:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/7270d7ad41d6061a25b950c6bf91d638bd9aacb113200a8c8d57a055fd67/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d54fb5b31dea401a41af3f8a7d2512e9b6a6a005491e6166c7e4ffab9639a9c", size = 340459, upload-time = "2026-05-19T10:09:45.452Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8d/302cb2057b7513327b4d575cff6b1d066ee6431a5357fc3f8867cd684406/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d5d6090cdc1b7c9e780dfb04949a990adb1e301a2fc0bbcee7de4638d33f9a", size = 344469, upload-time = "2026-05-19T10:09:46.864Z" }, +] + [[package]] name = "jmespath" version = "1.1.0" @@ -2471,7 +2522,7 @@ wheels = [ [[package]] name = "ogx-client" -version = "1.1.2" +version = "1.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -2490,9 +2541,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/15/5f02de6eba355443a5687abfc8474beec4ac0aae3357629dc889547f62cb/ogx_client-1.1.2.tar.gz", hash = "sha256:2bb2e7d9100e6332d45dc003777027f9a2f7e9ec82d77cfa6c08ccb670172657", size = 440847, upload-time = "2026-06-17T14:11:44.312Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ac/2fd77ab57ef26b8689d29d3f0a2e9020966282797b715ecf537806ca3486/ogx_client-1.1.3.tar.gz", hash = "sha256:d61a6f4cd996a5ce5cd6eb1389215f520cc1ca49d485b454d00faa021d4f4f0a", size = 440850, upload-time = "2026-06-24T15:07:27.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/24/4f/c17e5520c0fc282b4b1654bdd5432c7c5e393ce1c5a5b305b34e5ce3ea14/ogx_client-1.1.2-py3-none-any.whl", hash = "sha256:c8b0a0657231da8151ed7267261d3dd7713482ef7d19d97adc13bb42ca25a5da", size = 314013, upload-time = "2026-06-17T14:11:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f0/be21078666dc51b30a92dcd83e7711f923230b2cdd4a274994c36059233d/ogx_client-1.1.3-py3-none-any.whl", hash = "sha256:56390f857807f99c1927ab2de46d3427d63970d55533945c92e575fbe51d2252", size = 314011, upload-time = "2026-06-24T15:07:25.85Z" }, ] [[package]] @@ -2533,6 +2584,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/bb/410a760694f8ae7bbfc5fa81ccbeb7da241e6d520ee02a333a439cf462a2/onnxruntime-1.25.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd83ef5c10cfc051a1cb465db692d57b996a1bc75a2a97b161398e29cdbc47ff", size = 18021727, upload-time = "2026-04-27T22:00:13.846Z" }, ] +[[package]] +name = "openai" +version = "1.109.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/a1/a303104dc55fc546a3f6914c842d3da471c64eec92043aef8f652eb6c524/openai-1.109.1.tar.gz", hash = "sha256:d173ed8dbca665892a6db099b4a2dfac624f94d20a93f46eb0b56aae940ed869", size = 564133, upload-time = "2025-09-24T13:00:53.075Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/2a/7dd3d207ec669cacc1f186fd856a0f61dbc255d24f6fdc1a6715d6051b0f/openai-1.109.1-py3-none-any.whl", hash = "sha256:6bcaf57086cf59159b8e27447e4e7dd019db5d29a438072fbd49c290c7e65315", size = 948627, upload-time = "2025-09-24T13:00:50.754Z" }, +] + [[package]] name = "opencv-python" version = "4.13.0.92" @@ -3762,14 +3832,14 @@ wheels = [ [[package]] name = "s3transfer" -version = "0.18.0" +version = "0.19.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e0/1f/12417f7f493fc45e1f9fd5d4a9b6c125cf8d2cf3f8ddbdfab3e76406e9d6/s3transfer-0.18.0.tar.gz", hash = "sha256:3760b8b7ec1315da54048b2d626276732bee4300d054d492d4e1d43e20d4ecbd", size = 160560, upload-time = "2026-05-28T19:39:09.124Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/94/dcdaeb1713cab9c84def276cfac7388b17c7d9855bbcfe88d77e4dbafd44/s3transfer-0.19.0.tar.gz", hash = "sha256:ce436931687addc4c1712d52d40b32f53e88315723f107ffa20ba82b05a0f685", size = 165171, upload-time = "2026-06-16T19:44:51.599Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/58/a58fc997655386daa2e25784e30c288aa3e3819e401f77029ee4899fb55a/s3transfer-0.18.0-py3-none-any.whl", hash = "sha256:239c13b09e65ad0346e1be7348b8a202dcad44ac7ea7c6eb858fc881dce739b6", size = 88572, upload-time = "2026-05-28T19:39:07.999Z" }, + { url = "https://files.pythonhosted.org/packages/46/5f/4c174edad94f82de888ac00a5ddd8d07b35609b6c94f0bdf4d74af57703e/s3transfer-0.19.0-py3-none-any.whl", hash = "sha256:777cc2415536f1debadb5c2ef7779275d0fc0fe0e042411cdd6caebeb2685262", size = 90101, upload-time = "2026-06-16T19:44:50.439Z" }, ] [[package]]