From d92d349fffe6726ca1b0c07ea455bbca17538054 Mon Sep 17 00:00:00 2001 From: r266-tech Date: Mon, 29 Jun 2026 08:58:03 +0800 Subject: [PATCH] fix(clients): thread recall min_scores through the maintained Python SDK wrapper #2422 added the public RecallRequest.min_scores (per-stage score floors) to the HTTP/MCP API and the generated clients, but the hand-maintained high-level Python wrapper (hindsight_client.recall/arecall) never got it, so high-level SDK users can't use the feature without dropping to the raw generated client. Thread an optional min_scores dict through recall()/arecall() into RecallRequest, mirroring the existing tag_groups dict->from_dict pattern. Unknown keys raise ValueError so a misspelled floor fails loud instead of silently applying no filter. Parity test mirrors tests/test_recall_prefer_observations.py. Follow-up to #2422. --- .../hindsight_client/hindsight_client.py | 27 +++++++++ .../python/tests/test_recall_min_scores.py | 57 +++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 hindsight-clients/python/tests/test_recall_min_scores.py diff --git a/hindsight-clients/python/hindsight_client/hindsight_client.py b/hindsight-clients/python/hindsight_client/hindsight_client.py index 2549f889f0..5b1dbd2757 100644 --- a/hindsight-clients/python/hindsight_client/hindsight_client.py +++ b/hindsight-clients/python/hindsight_client/hindsight_client.py @@ -410,6 +410,7 @@ def recall( tags_match: Literal["any", "all", "any_strict", "all_strict", "exact"] = "any", tag_groups: list[dict[str, Any]] | None = None, prefer_observations: bool = False, + min_scores: dict[str, float] | None = None, ) -> RecallResponse: """ Recall memories using semantic similarity (sync wrapper — prefer :meth:`arecall` in async code). @@ -438,6 +439,10 @@ def recall( "observation", drop any raw fact a returned observation was consolidated from, so the observation supersedes it (no duplicate content). Disabled by default; no effect unless "observation" and at least one raw type are both in ``types``. + min_scores: Optional per-stage score floors, e.g. ``{"semantic": 0.2, "final": 0.5}``. + ``semantic`` and ``keyword`` are retrieval-level cutoffs; ``reranker`` and ``final`` + are applied to the scored results after reranking. Any omitted stage imposes no floor. + Unknown keys raise ``ValueError`` (rather than silently applying no floor). Returns: RecallResponse with results, optional entities, optional chunks, optional source_facts, and optional trace @@ -461,6 +466,7 @@ def recall( tags_match=tags_match, tag_groups=tag_groups, prefer_observations=prefer_observations, + min_scores=min_scores, ) ) @@ -890,6 +896,7 @@ async def arecall( tags_match: Literal["any", "all", "any_strict", "all_strict", "exact"] = "any", tag_groups: list[dict[str, Any]] | None = None, prefer_observations: bool = False, + min_scores: dict[str, float] | None = None, ) -> RecallResponse: """ Recall memories using semantic similarity (async — preferred over :meth:`recall`). @@ -923,6 +930,10 @@ async def arecall( "observation", drop any raw fact a returned observation was consolidated from, so the observation supersedes it (no duplicate content). Disabled by default; no effect unless "observation" and at least one raw type are both in ``types``. + min_scores: Optional per-stage score floors, e.g. ``{"semantic": 0.2, "final": 0.5}``. + ``semantic`` and ``keyword`` are retrieval-level cutoffs; ``reranker`` and ``final`` + are applied to the scored results after reranking. Any omitted stage imposes no floor. + Unknown keys raise ``ValueError`` (rather than silently applying no floor). Returns: RecallResponse with results, optional entities, optional chunks, optional source_facts, and optional trace @@ -950,6 +961,21 @@ async def arecall( tag_groups_objs = [RecallRequestTagGroupsInner.from_dict(tg) for tg in tag_groups] + min_scores_obj = None + if min_scores is not None: + from hindsight_client_api.models.min_scores import MinScores + + allowed_min_scores = {"semantic", "keyword", "reranker", "final"} + unknown = set(min_scores) - allowed_min_scores + if unknown: + # Fail loud instead of silently dropping a misspelled floor, which + # would make the caller believe filtering is active when it is not. + raise ValueError( + f"Unknown min_scores keys: {sorted(unknown)}. " + f"Allowed keys: {sorted(allowed_min_scores)}." + ) + min_scores_obj = MinScores.from_dict(min_scores) + request_obj = recall_request.RecallRequest( query=query, types=types, @@ -962,6 +988,7 @@ async def arecall( tags=tags, tags_match=tags_match, tag_groups=tag_groups_objs, + min_scores=min_scores_obj, ) return await self._memory_api.recall_memories(bank_id, request_obj, _request_timeout=self._timeout) diff --git a/hindsight-clients/python/tests/test_recall_min_scores.py b/hindsight-clients/python/tests/test_recall_min_scores.py new file mode 100644 index 0000000000..bbb93957cc --- /dev/null +++ b/hindsight-clients/python/tests/test_recall_min_scores.py @@ -0,0 +1,57 @@ +"""The maintained wrapper threads min_scores into the recall request.""" + +from unittest.mock import MagicMock + +import pytest + +from hindsight_client import Hindsight + + +def _capture_recall(monkeypatch, client, captured): + async def fake_recall(bank_id, request_obj, _request_timeout=None): + captured["request"] = request_obj + return MagicMock(results=[]) + + monkeypatch.setattr(client._memory_api, "recall_memories", fake_recall) + + +def test_recall_threads_min_scores(monkeypatch): + client = Hindsight(base_url="http://example.invalid") + captured: dict[str, object] = {} + _capture_recall(monkeypatch, client, captured) + + client.recall( + "test-bank", + "q", + min_scores={"semantic": 0.2, "final": 0.5}, + ) + + min_scores = captured["request"].min_scores + assert min_scores is not None + assert min_scores.semantic == 0.2 + assert min_scores.final == 0.5 + # Unspecified stages impose no floor. + assert min_scores.keyword is None + assert min_scores.reranker is None + + +def test_recall_min_scores_defaults_none(monkeypatch): + client = Hindsight(base_url="http://example.invalid") + captured: dict[str, object] = {} + _capture_recall(monkeypatch, client, captured) + + client.recall("test-bank", "q") + + assert captured["request"].min_scores is None + + +def test_recall_min_scores_rejects_unknown_key(monkeypatch): + client = Hindsight(base_url="http://example.invalid") + captured: dict[str, object] = {} + _capture_recall(monkeypatch, client, captured) + + # A typo must fail loud, not silently apply no floor. + with pytest.raises(ValueError, match="sematic"): + client.recall("test-bank", "q", min_scores={"sematic": 0.8}) + + assert "request" not in captured