diff --git a/hindsight-api-slim/hindsight_api/api/http.py b/hindsight-api-slim/hindsight_api/api/http.py index c357f4116..37b4b24bb 100644 --- a/hindsight-api-slim/hindsight_api/api/http.py +++ b/hindsight-api-slim/hindsight_api/api/http.py @@ -171,6 +171,12 @@ class RecallRequest(BaseModel): description="Compound tag filter using boolean groups. Groups in the list are AND-ed. " "Each group is a leaf {tags, match} or compound {and: [...]}, {or: [...]}, {not: ...}.", ) + retrieval_weights: dict[str, float] | None = Field( + default=None, + description="Per-strategy weights for Reciprocal Rank Fusion. Keys: 'semantic', 'bm25', " + "'graph', 'temporal'. Values are multipliers (1.0 = default, 2.0 = double influence, " + "0.0 = disabled). Omitted keys default to the bank/server configuration.", + ) @field_validator("query") @classmethod @@ -3215,6 +3221,7 @@ async def api_recall( tags=request.tags, tags_match=request.tags_match, tag_groups=request.tag_groups, + retrieval_weights=request.retrieval_weights, ) # Convert core MemoryFact objects to API RecallResult objects (excluding internal metrics) diff --git a/hindsight-api-slim/hindsight_api/config.py b/hindsight-api-slim/hindsight_api/config.py index fd5f88652..67636bac9 100644 --- a/hindsight-api-slim/hindsight_api/config.py +++ b/hindsight-api-slim/hindsight_api/config.py @@ -445,6 +445,10 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]: ENV_RECALL_BUDGET_ADAPTIVE_HIGH = "HINDSIGHT_API_RECALL_BUDGET_ADAPTIVE_HIGH" ENV_RECALL_BUDGET_MIN = "HINDSIGHT_API_RECALL_BUDGET_MIN" ENV_RECALL_BUDGET_MAX = "HINDSIGHT_API_RECALL_BUDGET_MAX" +ENV_RECALL_WEIGHT_SEMANTIC = "HINDSIGHT_API_RECALL_WEIGHT_SEMANTIC" +ENV_RECALL_WEIGHT_BM25 = "HINDSIGHT_API_RECALL_WEIGHT_BM25" +ENV_RECALL_WEIGHT_GRAPH = "HINDSIGHT_API_RECALL_WEIGHT_GRAPH" +ENV_RECALL_WEIGHT_TEMPORAL = "HINDSIGHT_API_RECALL_WEIGHT_TEMPORAL" # Audit log settings ENV_AUDIT_LOG_ENABLED = "HINDSIGHT_API_AUDIT_LOG_ENABLED" @@ -677,6 +681,14 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]: DEFAULT_RECALL_BUDGET_MIN = 20 # Floor for the adaptive function DEFAULT_RECALL_BUDGET_MAX = 2000 # Ceiling for the adaptive function +# Recall retrieval weights (RRF fusion) +# Per-strategy multipliers for Reciprocal Rank Fusion. +# 1.0 = default (equal weight), 2.0 = double influence, 0.0 = disabled. +DEFAULT_RECALL_WEIGHT_SEMANTIC = 1.0 +DEFAULT_RECALL_WEIGHT_BM25 = 1.0 +DEFAULT_RECALL_WEIGHT_GRAPH = 1.0 +DEFAULT_RECALL_WEIGHT_TEMPORAL = 1.0 + # Disposition defaults (None = not set, fall back to bank DB value or 3) DEFAULT_DISPOSITION_SKEPTICISM = None DEFAULT_DISPOSITION_LITERALISM = None @@ -1122,6 +1134,12 @@ class HindsightConfig: recall_budget_min: int recall_budget_max: int + # Recall retrieval weights (hierarchical - can be overridden per bank or per request) + recall_weight_semantic: float + recall_weight_bm25: float + recall_weight_graph: float + recall_weight_temporal: float + # Disposition settings (hierarchical - can be overridden per bank; None = fall back to DB) disposition_skepticism: int | None disposition_literalism: int | None @@ -1261,6 +1279,11 @@ class HindsightConfig: "recall_budget_adaptive_high", "recall_budget_min", "recall_budget_max", + # Recall retrieval weights + "recall_weight_semantic", + "recall_weight_bm25", + "recall_weight_graph", + "recall_weight_temporal", # Disposition settings "disposition_skepticism", "disposition_literalism", @@ -1841,6 +1864,11 @@ def from_env(cls) -> "HindsightConfig": ), recall_budget_min=int(os.getenv(ENV_RECALL_BUDGET_MIN, str(DEFAULT_RECALL_BUDGET_MIN))), recall_budget_max=int(os.getenv(ENV_RECALL_BUDGET_MAX, str(DEFAULT_RECALL_BUDGET_MAX))), + # Recall retrieval weights + recall_weight_semantic=float(os.getenv(ENV_RECALL_WEIGHT_SEMANTIC, str(DEFAULT_RECALL_WEIGHT_SEMANTIC))), + recall_weight_bm25=float(os.getenv(ENV_RECALL_WEIGHT_BM25, str(DEFAULT_RECALL_WEIGHT_BM25))), + recall_weight_graph=float(os.getenv(ENV_RECALL_WEIGHT_GRAPH, str(DEFAULT_RECALL_WEIGHT_GRAPH))), + recall_weight_temporal=float(os.getenv(ENV_RECALL_WEIGHT_TEMPORAL, str(DEFAULT_RECALL_WEIGHT_TEMPORAL))), # Disposition settings (None = fall back to DB value) disposition_skepticism=int(os.getenv(ENV_DISPOSITION_SKEPTICISM)) if os.getenv(ENV_DISPOSITION_SKEPTICISM) diff --git a/hindsight-api-slim/hindsight_api/engine/interface.py b/hindsight-api-slim/hindsight_api/engine/interface.py index af29b4e50..94ea10547 100644 --- a/hindsight-api-slim/hindsight_api/engine/interface.py +++ b/hindsight-api-slim/hindsight_api/engine/interface.py @@ -82,6 +82,7 @@ async def recall_async( include_chunks: bool = False, max_chunk_tokens: int = 8192, request_context: "RequestContext", + retrieval_weights: dict[str, float] | None = None, ) -> "RecallResult": """ Recall memories relevant to a query. diff --git a/hindsight-api-slim/hindsight_api/engine/memory_engine.py b/hindsight-api-slim/hindsight_api/engine/memory_engine.py index 4f633e91b..b048c498b 100644 --- a/hindsight-api-slim/hindsight_api/engine/memory_engine.py +++ b/hindsight-api-slim/hindsight_api/engine/memory_engine.py @@ -2655,6 +2655,7 @@ async def recall_async( tag_groups: list[TagGroup] | None = None, created_after: datetime | None = None, created_before: datetime | None = None, + retrieval_weights: dict[str, float] | None = None, _connection_budget: int | None = None, _quiet: bool = False, ) -> RecallResultModel: @@ -2754,6 +2755,18 @@ async def recall_async( budget_config_dict = await self._config_resolver.get_bank_config(bank_id, request_context) thinking_budget = _resolve_thinking_budget(budget_config_dict, budget, max_tokens) + # Build effective retrieval weights: config defaults + per-request overrides + effective_weights: dict[str, float] = { + "semantic": float(budget_config_dict.get("recall_weight_semantic", 1.0)), + "bm25": float(budget_config_dict.get("recall_weight_bm25", 1.0)), + "graph": float(budget_config_dict.get("recall_weight_graph", 1.0)), + "temporal": float(budget_config_dict.get("recall_weight_temporal", 1.0)), + } + if retrieval_weights: + effective_weights.update(retrieval_weights) + # Only pass weights if any differ from default (1.0) + rrf_weights = effective_weights if any(w != 1.0 for w in effective_weights.values()) else None + # Log recall start with tags if present (skip if quiet mode for internal operations) if not _quiet: tags_info = f", tags={tags} ({tags_match})" if tags else "" @@ -2807,6 +2820,7 @@ async def recall_async( include_source_facts=include_source_facts, max_source_facts_tokens=max_source_facts_tokens, max_source_facts_tokens_per_observation=max_source_facts_tokens_per_observation, + rrf_weights=rrf_weights, ) break # Success - exit retry loop except Exception as e: @@ -2938,6 +2952,7 @@ async def _search_with_retries( include_source_facts: bool = False, max_source_facts_tokens: int = 4096, max_source_facts_tokens_per_observation: int = -1, + rrf_weights: dict[str, float] | None = None, ) -> RecallResultModel: """ Search implementation with modular retrieval and reranking. @@ -3265,10 +3280,14 @@ def to_tuple_format(results): # Merge 3 or 4 result lists depending on temporal constraint if temporal_results: merged_candidates = reciprocal_rank_fusion( - [semantic_results, bm25_results, graph_results, temporal_results] + [semantic_results, bm25_results, graph_results, temporal_results], + weights=rrf_weights, ) else: - merged_candidates = reciprocal_rank_fusion([semantic_results, bm25_results, graph_results]) + merged_candidates = reciprocal_rank_fusion( + [semantic_results, bm25_results, graph_results], + weights=rrf_weights, + ) step_duration = time.time() - step_start log_buffer.append( diff --git a/hindsight-api-slim/hindsight_api/engine/search/fusion.py b/hindsight-api-slim/hindsight_api/engine/search/fusion.py index b9bff3040..b51b1d51a 100644 --- a/hindsight-api-slim/hindsight_api/engine/search/fusion.py +++ b/hindsight-api-slim/hindsight_api/engine/search/fusion.py @@ -7,15 +7,25 @@ from .types import MergedCandidate, RetrievalResult -def reciprocal_rank_fusion(result_lists: list[list[RetrievalResult]], k: int = 60) -> list[MergedCandidate]: +def reciprocal_rank_fusion( + result_lists: list[list[RetrievalResult]], + k: int = 60, + weights: dict[str, float] | None = None, +) -> list[MergedCandidate]: """ Merge multiple ranked result lists using Reciprocal Rank Fusion. - RRF formula: score(d) = sum_over_lists(1 / (k + rank(d))) + RRF formula: score(d) = sum_over_lists(w_i / (k + rank(d))) + + When weights are provided, each retrieval strategy's contribution is + multiplied by its weight. A weight of 2.0 doubles that strategy's + influence; 0.0 disables it entirely. Default weight is 1.0 (unweighted). Args: result_lists: List of result lists, each containing RetrievalResult objects k: Constant for RRF formula (default: 60) + weights: Optional mapping of strategy name to weight, e.g. + {"semantic": 1.0, "bm25": 1.0, "graph": 2.0, "temporal": 1.0} Returns: Merged list of MergedCandidate objects, sorted by RRF score @@ -25,8 +35,14 @@ def reciprocal_rank_fusion(result_lists: list[list[RetrievalResult]], k: int = 6 bm25_results = [RetrievalResult(...), RetrievalResult(...), ...] graph_results = [RetrievalResult(...), RetrievalResult(...), ...] + # Unweighted (default) merged = reciprocal_rank_fusion([semantic_results, bm25_results, graph_results]) - # Returns: [MergedCandidate(...), MergedCandidate(...), ...] + + # With graph retrieval weighted 2x + merged = reciprocal_rank_fusion( + [semantic_results, bm25_results, graph_results], + weights={"graph": 2.0}, + ) """ # Track scores from each list rrf_scores = {} @@ -37,6 +53,7 @@ def reciprocal_rank_fusion(result_lists: list[list[RetrievalResult]], k: int = 6 for source_idx, results in enumerate(result_lists): source_name = source_names[source_idx] if source_idx < len(source_names) else f"source_{source_idx}" + weight = (weights or {}).get(source_name, 1.0) for rank, retrieval in enumerate(results, start=1): # Type check to catch tuple issues @@ -56,12 +73,12 @@ def reciprocal_rank_fusion(result_lists: list[list[RetrievalResult]], k: int = 6 if doc_id not in all_retrievals: all_retrievals[doc_id] = retrieval - # Calculate RRF score contribution + # Calculate weighted RRF score contribution if doc_id not in rrf_scores: rrf_scores[doc_id] = 0.0 source_ranks[doc_id] = {} - rrf_scores[doc_id] += 1.0 / (k + rank) + rrf_scores[doc_id] += weight / (k + rank) source_ranks[doc_id][f"{source_name}_rank"] = rank # Combine into final results with metadata diff --git a/hindsight-api-slim/tests/test_weighted_rrf.py b/hindsight-api-slim/tests/test_weighted_rrf.py new file mode 100644 index 000000000..5d8cd906c --- /dev/null +++ b/hindsight-api-slim/tests/test_weighted_rrf.py @@ -0,0 +1,192 @@ +""" +Tests for weighted Reciprocal Rank Fusion. + +Validates that per-strategy weights correctly influence RRF merge ordering. +""" + +import pytest + +from hindsight_api.engine.search.fusion import reciprocal_rank_fusion +from hindsight_api.engine.search.types import RetrievalResult + + +def _make_result(id: str, **kwargs) -> RetrievalResult: + """Create a minimal RetrievalResult for testing.""" + return RetrievalResult(id=id, text=f"text-{id}", fact_type="world", **kwargs) + + +class TestWeightedRRF: + """Tests for weighted Reciprocal Rank Fusion.""" + + def test_unweighted_default(self): + """Without weights, all strategies contribute equally (existing behavior).""" + semantic = [_make_result("a"), _make_result("b")] + bm25 = [_make_result("b"), _make_result("a")] + graph = [_make_result("a"), _make_result("c")] + + merged = reciprocal_rank_fusion([semantic, bm25, graph]) + + # "a" appears rank 1 in semantic + graph, rank 2 in bm25 → highest score + assert merged[0].id == "a" + # All candidates present + assert {m.id for m in merged} == {"a", "b", "c"} + + def test_weights_none_same_as_unweighted(self): + """Passing weights=None produces identical results to no weights.""" + semantic = [_make_result("a"), _make_result("b")] + bm25 = [_make_result("b"), _make_result("a")] + graph = [_make_result("c")] + + merged_default = reciprocal_rank_fusion([semantic, bm25, graph]) + merged_none = reciprocal_rank_fusion([semantic, bm25, graph], weights=None) + + assert [m.id for m in merged_default] == [m.id for m in merged_none] + for d, n in zip(merged_default, merged_none): + assert d.rrf_score == pytest.approx(n.rrf_score) + + def test_all_weights_one_same_as_unweighted(self): + """Explicit weights of 1.0 produce identical results.""" + semantic = [_make_result("a"), _make_result("b")] + bm25 = [_make_result("b"), _make_result("a")] + graph = [_make_result("c")] + + merged_default = reciprocal_rank_fusion([semantic, bm25, graph]) + merged_ones = reciprocal_rank_fusion( + [semantic, bm25, graph], + weights={"semantic": 1.0, "bm25": 1.0, "graph": 1.0}, + ) + + assert [m.id for m in merged_default] == [m.id for m in merged_ones] + + def test_high_graph_weight_boosts_graph_results(self): + """A high graph weight should boost items that rank well in graph retrieval.""" + # "a" is rank 1 in semantic only + # "b" is rank 1 in graph only + semantic = [_make_result("a")] + bm25 = [] + graph = [_make_result("b")] + + # Without weights: "a" and "b" tie (both rank 1 in one list) + merged_equal = reciprocal_rank_fusion([semantic, bm25, graph]) + scores_equal = {m.id: m.rrf_score for m in merged_equal} + assert scores_equal["a"] == pytest.approx(scores_equal["b"]) + + # With graph weight 3.0: "b" should score higher + merged_weighted = reciprocal_rank_fusion( + [semantic, bm25, graph], + weights={"graph": 3.0}, + ) + scores_weighted = {m.id: m.rrf_score for m in merged_weighted} + assert scores_weighted["b"] > scores_weighted["a"] + assert merged_weighted[0].id == "b" + + def test_zero_weight_disables_strategy(self): + """A weight of 0.0 should completely disable a strategy's contribution.""" + # "a" is only in semantic, "b" is only in graph + semantic = [_make_result("a")] + bm25 = [] + graph = [_make_result("b")] + + merged = reciprocal_rank_fusion( + [semantic, bm25, graph], + weights={"semantic": 0.0}, + ) + + scores = {m.id: m.rrf_score for m in merged} + assert scores["a"] == 0.0 # semantic disabled + assert scores["b"] > 0.0 # graph still contributes + assert merged[0].id == "b" + + def test_partial_weights_default_to_one(self): + """Omitted strategy keys default to weight 1.0.""" + semantic = [_make_result("a")] + bm25 = [_make_result("b")] + graph = [_make_result("c")] + + # Only specify graph weight, others should be 1.0 + merged = reciprocal_rank_fusion( + [semantic, bm25, graph], + weights={"graph": 2.0}, + ) + + scores = {m.id: m.rrf_score for m in merged} + # "a" (semantic, w=1.0) and "b" (bm25, w=1.0) should have equal scores + assert scores["a"] == pytest.approx(scores["b"]) + # "c" (graph, w=2.0) should have double the score + assert scores["c"] == pytest.approx(scores["a"] * 2.0) + + def test_weights_with_temporal(self): + """Weights work correctly with 4 retrieval strategies including temporal.""" + semantic = [_make_result("a")] + bm25 = [_make_result("b")] + graph = [_make_result("c")] + temporal = [_make_result("d")] + + merged = reciprocal_rank_fusion( + [semantic, bm25, graph, temporal], + weights={"temporal": 5.0}, + ) + + scores = {m.id: m.rrf_score for m in merged} + # "d" (temporal, w=5.0) should have highest score + assert merged[0].id == "d" + assert scores["d"] == pytest.approx(scores["a"] * 5.0) + + def test_weight_changes_ranking_order(self): + """Demonstrate that weights can reverse the ranking of two items.""" + # Both "a" and "b" appear in semantic and graph, but in different positions + # Semantic: a=1, b=2 → Graph: b=1, a=2 + semantic = [_make_result("a"), _make_result("b")] + bm25 = [] + graph = [_make_result("b"), _make_result("a")] + + # Unweighted: tied (both appear at rank 1 and rank 2, once each) + merged_equal = reciprocal_rank_fusion([semantic, bm25, graph]) + scores_equal = {m.id: m.rrf_score for m in merged_equal} + assert scores_equal["a"] == pytest.approx(scores_equal["b"]) + + # Weight graph 3x: "b" wins because it's rank 1 in the heavier strategy + merged_graph_heavy = reciprocal_rank_fusion( + [semantic, bm25, graph], + weights={"graph": 3.0}, + ) + assert merged_graph_heavy[0].id == "b" + + # Weight semantic 3x: "a" wins + merged_semantic_heavy = reciprocal_rank_fusion( + [semantic, bm25, graph], + weights={"semantic": 3.0}, + ) + assert merged_semantic_heavy[0].id == "a" + + def test_source_ranks_preserved_with_weights(self): + """Source ranks should be unaffected by weights — only scores change.""" + semantic = [_make_result("a"), _make_result("b")] + bm25 = [_make_result("b")] + graph = [_make_result("a")] + + merged = reciprocal_rank_fusion( + [semantic, bm25, graph], + weights={"graph": 10.0}, + ) + + ranks = {m.id: m.source_ranks for m in merged} + assert ranks["a"]["semantic_rank"] == 1 + assert ranks["a"]["graph_rank"] == 1 + assert ranks["b"]["semantic_rank"] == 2 + assert ranks["b"]["bm25_rank"] == 1 + + def test_rrf_rank_reflects_weighted_order(self): + """rrf_rank should reflect the weighted score ordering.""" + semantic = [_make_result("a")] + bm25 = [] + graph = [_make_result("b")] + + merged = reciprocal_rank_fusion( + [semantic, bm25, graph], + weights={"graph": 2.0}, + ) + + rank_map = {m.id: m.rrf_rank for m in merged} + assert rank_map["b"] == 1 # graph-boosted item ranks first + assert rank_map["a"] == 2 diff --git a/hindsight-clients/go/api/openapi.yaml b/hindsight-clients/go/api/openapi.yaml index e038cf91a..e2a59bdeb 100644 --- a/hindsight-clients/go/api/openapi.yaml +++ b/hindsight-clients/go/api/openapi.yaml @@ -5880,6 +5880,16 @@ components: $ref: '#/components/schemas/MentalModelTrigger_Input_tag_groups_inner' nullable: true type: array + retrieval_weights: + additionalProperties: + type: number + description: "Per-strategy weights for Reciprocal Rank Fusion. Keys: 'semantic',\ + \ 'bm25', 'graph', 'temporal'. Values are multipliers (1.0 = default, 2.0\ + \ = double influence, 0.0 = disabled). Omitted keys default to the bank/server\ + \ configuration." + nullable: true + title: Retrieval Weights + type: object required: - query title: RecallRequest diff --git a/hindsight-clients/go/model_recall_request.go b/hindsight-clients/go/model_recall_request.go index f55999f9b..ad473a330 100644 --- a/hindsight-clients/go/model_recall_request.go +++ b/hindsight-clients/go/model_recall_request.go @@ -33,6 +33,8 @@ type RecallRequest struct { // How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged), 'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged). TagsMatch *string `json:"tags_match,omitempty"` TagGroups []MentalModelTriggerInputTagGroupsInner `json:"tag_groups,omitempty"` + // Per-strategy weights for Reciprocal Rank Fusion. Keys: 'semantic', 'bm25', 'graph', 'temporal'. Values are multipliers (1.0 = default, 2.0 = double influence, 0.0 = disabled). Omitted keys default to the bank/server configuration. + RetrievalWeights map[string]float32 `json:"retrieval_weights,omitempty"` } type _RecallRequest RecallRequest @@ -392,6 +394,39 @@ func (o *RecallRequest) SetTagGroups(v []MentalModelTriggerInputTagGroupsInner) o.TagGroups = v } +// GetRetrievalWeights returns the RetrievalWeights field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *RecallRequest) GetRetrievalWeights() map[string]float32 { + if o == nil { + var ret map[string]float32 + return ret + } + return o.RetrievalWeights +} + +// GetRetrievalWeightsOk returns a tuple with the RetrievalWeights field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *RecallRequest) GetRetrievalWeightsOk() (*map[string]float32, bool) { + if o == nil || IsNil(o.RetrievalWeights) { + return nil, false + } + return &o.RetrievalWeights, true +} + +// HasRetrievalWeights returns a boolean if a field has been set. +func (o *RecallRequest) HasRetrievalWeights() bool { + if o != nil && !IsNil(o.RetrievalWeights) { + return true + } + + return false +} + +// SetRetrievalWeights gets a reference to the given map[string]float32 and assigns it to the RetrievalWeights field. +func (o *RecallRequest) SetRetrievalWeights(v map[string]float32) { + o.RetrievalWeights = v +} + func (o RecallRequest) MarshalJSON() ([]byte, error) { toSerialize,err := o.ToMap() if err != nil { @@ -430,6 +465,9 @@ func (o RecallRequest) ToMap() (map[string]interface{}, error) { if o.TagGroups != nil { toSerialize["tag_groups"] = o.TagGroups } + if o.RetrievalWeights != nil { + toSerialize["retrieval_weights"] = o.RetrievalWeights + } return toSerialize, nil } diff --git a/hindsight-clients/python/hindsight_client_api/models/recall_request.py b/hindsight-clients/python/hindsight_client_api/models/recall_request.py index 340b3dd7d..0bee16196 100644 --- a/hindsight-clients/python/hindsight_client_api/models/recall_request.py +++ b/hindsight-clients/python/hindsight_client_api/models/recall_request.py @@ -17,8 +17,8 @@ import re # noqa: F401 import json -from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr, field_validator -from typing import Any, ClassVar, Dict, List, Optional +from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictFloat, StrictInt, StrictStr, field_validator +from typing import Any, ClassVar, Dict, List, Optional, Union from hindsight_client_api.models.budget import Budget from hindsight_client_api.models.include_options import IncludeOptions from hindsight_client_api.models.mental_model_trigger_input_tag_groups_inner import MentalModelTriggerInputTagGroupsInner @@ -39,7 +39,8 @@ class RecallRequest(BaseModel): tags: Optional[List[StrictStr]] = None tags_match: Optional[StrictStr] = Field(default='any', description="How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged), 'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged).") tag_groups: Optional[List[MentalModelTriggerInputTagGroupsInner]] = None - __properties: ClassVar[List[str]] = ["query", "types", "budget", "max_tokens", "trace", "query_timestamp", "include", "tags", "tags_match", "tag_groups"] + retrieval_weights: Optional[Dict[str, Union[StrictFloat, StrictInt]]] = Field(default=None, description="Per-strategy weights for Reciprocal Rank Fusion. Keys: 'semantic', 'bm25', 'graph', 'temporal'. Values are multipliers (1.0 = default, 2.0 = double influence, 0.0 = disabled). Omitted keys default to the bank/server configuration.") + __properties: ClassVar[List[str]] = ["query", "types", "budget", "max_tokens", "trace", "query_timestamp", "include", "tags", "tags_match", "tag_groups", "retrieval_weights"] @field_validator('tags_match') def tags_match_validate_enum(cls, value): @@ -120,6 +121,11 @@ def to_dict(self) -> Dict[str, Any]: if self.tag_groups is None and "tag_groups" in self.model_fields_set: _dict['tag_groups'] = None + # set to None if retrieval_weights (nullable) is None + # and model_fields_set contains the field + if self.retrieval_weights is None and "retrieval_weights" in self.model_fields_set: + _dict['retrieval_weights'] = None + return _dict @classmethod @@ -141,7 +147,8 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "include": IncludeOptions.from_dict(obj["include"]) if obj.get("include") is not None else None, "tags": obj.get("tags"), "tags_match": obj.get("tags_match") if obj.get("tags_match") is not None else 'any', - "tag_groups": [MentalModelTriggerInputTagGroupsInner.from_dict(_item) for _item in obj["tag_groups"]] if obj.get("tag_groups") is not None else None + "tag_groups": [MentalModelTriggerInputTagGroupsInner.from_dict(_item) for _item in obj["tag_groups"]] if obj.get("tag_groups") is not None else None, + "retrieval_weights": obj.get("retrieval_weights") }) return _obj diff --git a/hindsight-clients/typescript/generated/client/client.gen.ts b/hindsight-clients/typescript/generated/client/client.gen.ts index c08d2bd47..e6d6f2d74 100644 --- a/hindsight-clients/typescript/generated/client/client.gen.ts +++ b/hindsight-clients/typescript/generated/client/client.gen.ts @@ -68,11 +68,9 @@ export const createClient = (config: Config = {}): Client => { const request: Client["request"] = async (options) => { // @ts-expect-error const { opts, url } = await beforeRequest(options); - // Exclude hey-api internal fields that conflict with Deno's RequestInit.client - const { client: _client, ...optsForRequest } = opts as typeof opts & { client?: unknown }; const requestInit: ReqInit = { redirect: "follow", - ...optsForRequest, + ...opts, body: getValidRequestBody(opts), }; diff --git a/hindsight-clients/typescript/generated/types.gen.ts b/hindsight-clients/typescript/generated/types.gen.ts index a5e1b7caa..8c8dd856f 100644 --- a/hindsight-clients/typescript/generated/types.gen.ts +++ b/hindsight-clients/typescript/generated/types.gen.ts @@ -2340,6 +2340,14 @@ export type RecallRequest = { * Compound tag filter using boolean groups. Groups in the list are AND-ed. Each group is a leaf {tags, match} or compound {and: [...]}, {or: [...]}, {not: ...}. */ tag_groups?: Array | null; + /** + * Retrieval Weights + * + * Per-strategy weights for Reciprocal Rank Fusion. Keys: 'semantic', 'bm25', 'graph', 'temporal'. Values are multipliers (1.0 = default, 2.0 = double influence, 0.0 = disabled). Omitted keys default to the bank/server configuration. + */ + retrieval_weights?: { + [key: string]: number; + } | null; }; /** diff --git a/hindsight-control-plane/src/app/api/recall/route.ts b/hindsight-control-plane/src/app/api/recall/route.ts index ce6ec1bcc..91c4c7f94 100644 --- a/hindsight-control-plane/src/app/api/recall/route.ts +++ b/hindsight-control-plane/src/app/api/recall/route.ts @@ -16,6 +16,7 @@ export async function POST(request: NextRequest) { query_timestamp, tags, tags_match, + retrieval_weights, } = body; const response = await sdk.recallMemories({ @@ -31,6 +32,7 @@ export async function POST(request: NextRequest) { query_timestamp, tags, tags_match, + retrieval_weights, }, }); diff --git a/hindsight-control-plane/src/lib/api.ts b/hindsight-control-plane/src/lib/api.ts index 25cddb7cc..46a592297 100644 --- a/hindsight-control-plane/src/lib/api.ts +++ b/hindsight-control-plane/src/lib/api.ts @@ -252,6 +252,12 @@ export class ControlPlaneClient { query_timestamp?: string; tags?: string[]; tags_match?: "any" | "all" | "any_strict" | "all_strict"; + retrieval_weights?: { + semantic?: number; + bm25?: number; + graph?: number; + temporal?: number; + }; }) { return this.fetchApi("/api/recall", { method: "POST", diff --git a/hindsight-docs/static/openapi.json b/hindsight-docs/static/openapi.json index 6fc7fb899..7e36efa5f 100644 --- a/hindsight-docs/static/openapi.json +++ b/hindsight-docs/static/openapi.json @@ -8993,6 +8993,21 @@ ], "title": "Tag Groups", "description": "Compound tag filter using boolean groups. Groups in the list are AND-ed. Each group is a leaf {tags, match} or compound {and: [...]}, {or: [...]}, {not: ...}." + }, + "retrieval_weights": { + "anyOf": [ + { + "additionalProperties": { + "type": "number" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Retrieval Weights", + "description": "Per-strategy weights for Reciprocal Rank Fusion. Keys: 'semantic', 'bm25', 'graph', 'temporal'. Values are multipliers (1.0 = default, 2.0 = double influence, 0.0 = disabled). Omitted keys default to the bank/server configuration." } }, "type": "object", diff --git a/skills/hindsight-docs/references/openapi.json b/skills/hindsight-docs/references/openapi.json index 6fc7fb899..7e36efa5f 100644 --- a/skills/hindsight-docs/references/openapi.json +++ b/skills/hindsight-docs/references/openapi.json @@ -8993,6 +8993,21 @@ ], "title": "Tag Groups", "description": "Compound tag filter using boolean groups. Groups in the list are AND-ed. Each group is a leaf {tags, match} or compound {and: [...]}, {or: [...]}, {not: ...}." + }, + "retrieval_weights": { + "anyOf": [ + { + "additionalProperties": { + "type": "number" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Retrieval Weights", + "description": "Per-strategy weights for Reciprocal Rank Fusion. Keys: 'semantic', 'bm25', 'graph', 'temporal'. Values are multipliers (1.0 = default, 2.0 = double influence, 0.0 = disabled). Omitted keys default to the bank/server configuration." } }, "type": "object",