Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions hindsight-clients/python/hindsight_client/hindsight_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand All @@ -461,6 +466,7 @@ def recall(
tags_match=tags_match,
tag_groups=tag_groups,
prefer_observations=prefer_observations,
min_scores=min_scores,
)
)

Expand Down Expand Up @@ -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`).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
57 changes: 57 additions & 0 deletions hindsight-clients/python/tests/test_recall_min_scores.py
Original file line number Diff line number Diff line change
@@ -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
Loading