From 49534b8083494fbc672b3b3eaf4b768fc78415ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Thu, 11 Jun 2026 16:39:56 +0200 Subject: [PATCH 1/9] feat(observations): enumerate + filter observations by scope Add an exact (set-equality) tag match mode, a list_observation_scopes engine method + GET /observations/scopes endpoint, and a scope filter in the control-plane Observations tab (list + graph views). A scope is the exact tag set an observation was consolidated under; the empty set is the global/untagged scope. Regenerated OpenAPI + clients + docs skill. --- hindsight-api-slim/hindsight_api/api/http.py | 56 ++++ .../hindsight_api/engine/memory_engine.py | 44 +++ .../hindsight_api/engine/search/tags.py | 36 ++- .../tests/test_graph_filtering.py | 79 +++++ .../tests/test_tags_visibility.py | 41 +++ hindsight-clients/go/api/openapi.yaml | 87 ++++++ hindsight-clients/go/api_memory.go | 122 ++++++++ .../go/model_observation_scope.go | 188 ++++++++++++ .../go/model_observation_scopes_response.go | 159 ++++++++++ .../python/.openapi-generator/FILES | 2 + .../python/hindsight_client_api/__init__.py | 2 + .../hindsight_client_api/api/memory_api.py | 279 ++++++++++++++++++ .../hindsight_client_api/models/__init__.py | 2 + .../models/mental_model_trigger_input.py | 4 +- .../models/mental_model_trigger_output.py | 4 +- .../models/observation_scope.py | 89 ++++++ .../models/observation_scopes_response.py | 95 ++++++ .../models/recall_request.py | 4 +- .../models/reflect_request.py | 4 +- .../models/tag_group_leaf.py | 4 +- .../typescript/generated/sdk.gen.ts | 17 ++ .../typescript/generated/types.gen.ts | 82 ++++- .../[bankId]/observations/scopes/route.ts | 41 +++ .../src/app/api/graph/route.ts | 11 +- .../src/components/data-view.tsx | 78 ++++- .../components/observation-scope-filter.tsx | 89 ++++++ hindsight-control-plane/src/lib/api.ts | 18 ++ hindsight-control-plane/src/messages/de.json | 5 +- hindsight-control-plane/src/messages/en.json | 5 +- hindsight-control-plane/src/messages/es.json | 5 +- hindsight-control-plane/src/messages/fr.json | 5 +- hindsight-control-plane/src/messages/ja.json | 5 +- hindsight-control-plane/src/messages/ko.json | 5 +- hindsight-control-plane/src/messages/pt.json | 5 +- .../src/messages/yue-Hant.json | 5 +- .../src/messages/zh-CN.json | 5 +- .../src/messages/zh-TW.json | 5 +- .../static/bank-template-schema.json | 6 +- hindsight-docs/static/openapi.json | 137 ++++++++- skills/hindsight-docs/references/openapi.json | 137 ++++++++- 40 files changed, 1917 insertions(+), 50 deletions(-) create mode 100644 hindsight-clients/go/model_observation_scope.go create mode 100644 hindsight-clients/go/model_observation_scopes_response.go create mode 100644 hindsight-clients/python/hindsight_client_api/models/observation_scope.py create mode 100644 hindsight-clients/python/hindsight_client_api/models/observation_scopes_response.py create mode 100644 hindsight-control-plane/src/app/api/banks/[bankId]/observations/scopes/route.ts create mode 100644 hindsight-control-plane/src/components/observation-scope-filter.tsx diff --git a/hindsight-api-slim/hindsight_api/api/http.py b/hindsight-api-slim/hindsight_api/api/http.py index b698409df6..841ee53116 100644 --- a/hindsight-api-slim/hindsight_api/api/http.py +++ b/hindsight-api-slim/hindsight_api/api/http.py @@ -1280,6 +1280,33 @@ class GraphDataResponse(BaseModel): limit: int +class ObservationScope(BaseModel): + """A distinct observation scope: an exact tag set plus its observation count.""" + + tags: list[str] = Field( + description="The exact tag set defining this scope (normalized order). Empty list is the global/untagged scope." + ) + count: int = Field(description="Number of observations that live under this scope") + + +class ObservationScopesResponse(BaseModel): + """Response model for the observation scopes enumeration endpoint.""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "scopes": [ + {"tags": ["user:alice"], "count": 12}, + {"tags": ["user:alice", "project:apollo"], "count": 4}, + {"tags": [], "count": 2}, + ] + } + } + ) + + scopes: list[ObservationScope] = Field(description="Distinct observation scopes, most populous first") + + class ListMemoryUnitsResponse(BaseModel): """Response model for list memory units endpoint.""" @@ -5771,6 +5798,35 @@ async def api_clear_observations(bank_id: str, request_context: RequestContext = logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/observations: {error_detail}") raise HTTPException(status_code=500, detail=str(e)) + @app.get( + "/v1/default/banks/{bank_id}/observations/scopes", + response_model=ObservationScopesResponse, + summary="List observation scopes", + description=( + "Enumerate the distinct scopes across a bank's observations. Each observation lives " + "under a scope: the exact set of tags it was consolidated with. Returns every distinct " + "scope (tag order normalized) with the number of observations in it; the empty tag list " + "is the global/untagged scope. Use a returned scope with the graph endpoint " + "(tags= & tags_match=exact) to filter observations to exactly that scope." + ), + operation_id="list_observation_scopes", + tags=["Memory"], + ) + async def api_list_observation_scopes(bank_id: str, request_context: RequestContext = Depends(get_request_context)): + """List the distinct observation scopes (exact tag sets) for a bank.""" + try: + return await app.state.memory.list_observation_scopes(bank_id, request_context=request_context) + except OperationValidationError as e: + raise HTTPException(status_code=e.status_code, detail=e.reason) + except (AuthenticationError, HTTPException): + raise + except Exception as e: + import traceback + + error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" + logger.error(f"Error in GET /v1/default/banks/{bank_id}/observations/scopes: {error_detail}") + raise HTTPException(status_code=500, detail=str(e)) + @app.post( "/v1/default/banks/{bank_id}/consolidation/recover", response_model=RecoverConsolidationResponse, diff --git a/hindsight-api-slim/hindsight_api/engine/memory_engine.py b/hindsight-api-slim/hindsight_api/engine/memory_engine.py index 1a5df8adf9..818e5b6f14 100644 --- a/hindsight-api-slim/hindsight_api/engine/memory_engine.py +++ b/hindsight-api-slim/hindsight_api/engine/memory_engine.py @@ -5718,6 +5718,46 @@ async def clear_observations( return {"deleted_count": count or 0} + async def list_observation_scopes( + self, + bank_id: str, + *, + request_context: "RequestContext", + ) -> dict[str, Any]: + """List the distinct scopes across a bank's observations. + + Every consolidated observation lives under a "scope": the exact set of + tags it was consolidated with. This enumerates each distinct scope (tag + order normalized so ``[a, b]`` and ``[b, a]`` collapse) together with the + number of observations in it. The empty list ``[]`` is the "global" scope + of untagged observations. Results are ordered most-populous first. + + Returns: + Dict with ``scopes``: list of ``{"tags": list[str], "count": int}``. + """ + await self._authenticate_tenant(request_context) + if self._operation_validator: + from hindsight_api.extensions import BankReadContext + + ctx = BankReadContext(bank_id=bank_id, operation="list_observation_scopes", request_context=request_context) + await self._validate_operation(self._operation_validator.validate_bank_read(ctx)) + backend = await self._get_backend() + async with acquire_with_retry(backend) as conn: + rows = await conn.fetch( + f""" + SELECT scope, COUNT(*) AS count + FROM ( + SELECT COALESCE(ARRAY(SELECT unnest(tags) ORDER BY 1), '{{}}'::text[]) AS scope + FROM {fq_table("memory_units")} + WHERE bank_id = $1 AND fact_type = 'observation' + ) s + GROUP BY scope + ORDER BY count DESC, scope + """, + bank_id, + ) + return {"scopes": [{"tags": list(r["scope"]), "count": r["count"]} for r in rows]} + async def retry_failed_consolidation( self, bank_id: str, @@ -6297,6 +6337,10 @@ async def get_graph_data( query_conditions.append(tag_clause.removeprefix("AND ")) param_count += 1 query_params.append(tags) + elif tags_match == "exact": + # Exact match with no tags is the "global" scope: rows that carry no + # tags at all. (Other match modes treat empty tags as "no filter".) + query_conditions.append("(tags IS NULL OR tags = '{}')") where_clause = "WHERE " + " AND ".join(query_conditions) if query_conditions else "" diff --git a/hindsight-api-slim/hindsight_api/engine/search/tags.py b/hindsight-api-slim/hindsight_api/engine/search/tags.py index 5eb999ffc5..a14032fc51 100644 --- a/hindsight-api-slim/hindsight_api/engine/search/tags.py +++ b/hindsight-api-slim/hindsight_api/engine/search/tags.py @@ -2,14 +2,18 @@ Tags filtering utilities for retrieval. Provides SQL building functions for filtering memories by tags. -Supports four matching modes via TagsMatch enum: +Supports five matching modes via TagsMatch enum: - "any": OR matching, includes untagged memories (default, backward compatible) - "all": AND matching, includes untagged memories - "any_strict": OR matching, excludes untagged memories - "all_strict": AND matching, excludes untagged memories +- "exact": set-equality matching, excludes untagged memories OR matching (any/any_strict): Memory matches if ANY of its tags overlap with request tags AND matching (all/all_strict): Memory matches if ALL request tags are present in its tags +EXACT matching: Memory matches only if its tag set EQUALS the request tag set (order- + independent). Used for observation "scope" filtering, where each observation lives + under exactly one scope (its full tag set) and "scope [a]" must not match "[a, b]". """ from __future__ import annotations @@ -18,7 +22,7 @@ from pydantic import BaseModel, ConfigDict, Field -TagsMatch = Literal["any", "all", "any_strict", "all_strict"] +TagsMatch = Literal["any", "all", "any_strict", "all_strict", "exact"] def _parse_tags_match(match: TagsMatch) -> tuple[str, bool]: @@ -38,6 +42,10 @@ def _parse_tags_match(match: TagsMatch) -> tuple[str, bool]: return "&&", False elif match == "all_strict": return "@>", False + elif match == "exact": + # Set equality is handled by the callers via `@> AND <@`; the operator + # here is unused. Untagged rows never equal a non-empty scope. + return "@>", False else: # Default to "any" behavior return "&&", True @@ -78,6 +86,13 @@ def build_tags_where_clause( return "", [], param_offset column = f"{table_alias}tags" if table_alias else "tags" + + if match == "exact": + # Set equality (order-independent): superset AND subset. Untagged rows + # (empty array) never satisfy `@>` of a non-empty scope, so they're excluded. + clause = f"AND ({column} @> ${param_offset} AND {column} <@ ${param_offset})" + return clause, [tags], param_offset + 1 + operator, include_untagged = _parse_tags_match(match) if include_untagged: @@ -115,6 +130,12 @@ def build_tags_where_clause_simple( return "" column = f"{table_alias}tags" if table_alias else "tags" + + if match == "exact": + # Set equality (order-independent): superset AND subset. Untagged rows + # (empty array) never satisfy `@>` of a non-empty scope, so they're excluded. + return f"AND ({column} @> ${param_num} AND {column} <@ ${param_num})" + operator, include_untagged = _parse_tags_match(match) if include_untagged: @@ -164,7 +185,11 @@ def filter_results_by_tags( # else: skip untagged else: result_tags_set = set(result_tags) - if is_any_match: + if match == "exact": + # Set equality: tag set must match the scope exactly + if result_tags_set == tags_set: + filtered.append(result) + elif is_any_match: # Any overlap if result_tags_set & tags_set: filtered.append(result) @@ -241,6 +266,9 @@ def _build_group_clause( """ if isinstance(group, TagGroupLeaf): column = f"{table_alias}tags" if table_alias else "tags" + if group.match == "exact": + clause = f"({column} @> ${param_offset} AND {column} <@ ${param_offset})" + return clause, [group.tags], param_offset + 1 operator, include_untagged = _parse_tags_match(group.match) if include_untagged: clause = f"({column} IS NULL OR {column} = '{{}}' OR {column} {operator} ${param_offset})" @@ -349,6 +377,8 @@ def _match_group(result: object, group: TagGroup) -> bool: return include_untagged else: result_tags_set = set(result_tags) + if group.match == "exact": + return result_tags_set == tags_set if is_any_match: return bool(result_tags_set & tags_set) else: diff --git a/hindsight-api-slim/tests/test_graph_filtering.py b/hindsight-api-slim/tests/test_graph_filtering.py index ccbb835e6a..f30bae21b6 100644 --- a/hindsight-api-slim/tests/test_graph_filtering.py +++ b/hindsight-api-slim/tests/test_graph_filtering.py @@ -269,3 +269,82 @@ async def test_graph_q_filter_empty_results(api_client, test_bank_id): assert response.status_code == 200 data = response.json() assert data["table_rows"] == [] + + +async def _seed_scoped_observations(memory, bank_id, request_context): + """Seed observations under scopes [a], [b], [a,b] (x2) and the global scope.""" + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + rows = [ + (uuid.uuid4(), "obs scope a", ["a"]), + (uuid.uuid4(), "obs scope b", ["b"]), + (uuid.uuid4(), "obs scope ab one", ["a", "b"]), + (uuid.uuid4(), "obs scope ab two", ["b", "a"]), # same scope as above, different order + (uuid.uuid4(), "obs global", []), + ] + async with memory._pool.acquire() as conn: + for obs_id, text, tags in rows: + await conn.execute( + """ + INSERT INTO memory_units (id, bank_id, text, fact_type, tags, proof_count) + VALUES ($1, $2, $3, 'observation', $4::text[], 1) + """, + obs_id, + bank_id, + text, + tags, + ) + return rows + + +@pytest.mark.asyncio +async def test_observation_scopes_enumeration(memory, api_client, test_bank_id, request_context): + """The scopes endpoint enumerates distinct tag sets (order-normalized) with counts.""" + await _seed_scoped_observations(memory, test_bank_id, request_context) + + response = await api_client.get(f"/v1/default/banks/{test_bank_id}/observations/scopes") + assert response.status_code == 200 + scopes = response.json()["scopes"] + + # [a,b] and [b,a] collapse into one scope with count 2; global scope is []. + as_map = {tuple(s["tags"]): s["count"] for s in scopes} + assert as_map == {("a",): 1, ("b",): 1, ("a", "b"): 2, (): 1} + # Most populous scope is first. + assert scopes[0]["tags"] == ["a", "b"] + + +@pytest.mark.asyncio +async def test_graph_exact_scope_filter(memory, api_client, test_bank_id, request_context): + """tags_match=exact filters observations to exactly one scope, not supersets.""" + await _seed_scoped_observations(memory, test_bank_id, request_context) + + # Exact scope [a] returns only the [a] observation, NOT the [a,b] ones. + response = await api_client.get( + f"/v1/default/banks/{test_bank_id}/graph", + params={"type": "observation", "tags": ["a"], "tags_match": "exact"}, + ) + assert response.status_code == 200 + texts = {row["text"] for row in response.json()["table_rows"]} + assert texts == {"obs scope a"} + + # Exact scope [a,b] returns both [a,b] observations regardless of stored order. + response = await api_client.get( + f"/v1/default/banks/{test_bank_id}/graph", + params={"type": "observation", "tags": ["a", "b"], "tags_match": "exact"}, + ) + assert response.status_code == 200 + texts = {row["text"] for row in response.json()["table_rows"]} + assert texts == {"obs scope ab one", "obs scope ab two"} + + +@pytest.mark.asyncio +async def test_graph_exact_global_scope_filter(memory, api_client, test_bank_id, request_context): + """tags_match=exact with no tags is the global scope: untagged observations only.""" + await _seed_scoped_observations(memory, test_bank_id, request_context) + + response = await api_client.get( + f"/v1/default/banks/{test_bank_id}/graph", + params={"type": "observation", "tags_match": "exact"}, + ) + assert response.status_code == 200 + texts = {row["text"] for row in response.json()["table_rows"]} + assert texts == {"obs global"} diff --git a/hindsight-api-slim/tests/test_tags_visibility.py b/hindsight-api-slim/tests/test_tags_visibility.py index 5aee05f052..138ea16f13 100644 --- a/hindsight-api-slim/tests/test_tags_visibility.py +++ b/hindsight-api-slim/tests/test_tags_visibility.py @@ -119,6 +119,23 @@ def test_tags_match_all_strict_uses_contains(self): result = build_tags_where_clause_simple(["user_a"], 5, match="all_strict") assert "@>" in result + # ---- Test "exact" mode (set equality, excludes untagged) ---- + + def test_tags_match_exact_uses_set_equality(self): + """When match='exact', should require superset AND subset (set equality).""" + result = build_tags_where_clause_simple(["user_a"], 5, match="exact") + assert "@>" in result # contains-all + assert "<@" in result # contained-by + # Both halves bind the same parameter + assert result.count("$5") == 2 + + def test_tags_match_exact_with_table_alias(self): + """Should include table alias on both halves of the exact clause.""" + result = build_tags_where_clause_simple(["user_a", "user_b"], 3, table_alias="mu.", match="exact") + assert result.count("mu.tags") == 2 + assert "@>" in result + assert "<@" in result + # ---- Test table alias with all modes ---- def test_tags_match_any_with_table_alias(self): @@ -214,6 +231,30 @@ def test_all_mode_requires_all_tags(self): tags_found = [r.tags for r in filtered] assert ["a", "b"] in tags_found + # ---- Test "exact" mode (set equality, excludes untagged) ---- + + def test_exact_mode_matches_only_equal_set(self): + """'exact' mode should match only results whose tag set equals the scope.""" + results = [MockResult(["a"]), MockResult(["a", "b"]), MockResult(["b"]), MockResult(None)] + filtered = filter_results_by_tags(results, ["a"], match="exact") + # Only the exact scope ["a"] matches; ["a", "b"] is a different scope. + assert len(filtered) == 1 + assert filtered[0].tags == ["a"] + + def test_exact_mode_is_order_independent(self): + """'exact' mode should treat tag order as irrelevant (set equality).""" + results = [MockResult(["b", "a"]), MockResult(["a"]), MockResult(["a", "b", "c"])] + filtered = filter_results_by_tags(results, ["a", "b"], match="exact") + assert len(filtered) == 1 + assert filtered[0].tags == ["b", "a"] + + def test_exact_mode_excludes_untagged(self): + """'exact' mode with a non-empty scope should exclude untagged results.""" + results = [MockResult(["a"]), MockResult(None), MockResult([])] + filtered = filter_results_by_tags(results, ["a"], match="exact") + assert len(filtered) == 1 + assert filtered[0].tags == ["a"] + def test_all_mode_includes_untagged(self): """'all' mode should include untagged results.""" results = [MockResult(["a", "b"]), MockResult(None), MockResult([])] diff --git a/hindsight-clients/go/api/openapi.yaml b/hindsight-clients/go/api/openapi.yaml index 87068636f1..620f79db57 100644 --- a/hindsight-clients/go/api/openapi.yaml +++ b/hindsight-clients/go/api/openapi.yaml @@ -2834,6 +2834,48 @@ paths: summary: Clear all observations tags: - Banks + /v1/default/banks/{bank_id}/observations/scopes: + get: + description: "Enumerate the distinct scopes across a bank's observations. Each\ + \ observation lives under a scope: the exact set of tags it was consolidated\ + \ with. Returns every distinct scope (tag order normalized) with the number\ + \ of observations in it; the empty tag list is the global/untagged scope.\ + \ Use a returned scope with the graph endpoint (tags= & tags_match=exact)\ + \ to filter observations to exactly that scope." + operationId: list_observation_scopes + parameters: + - explode: false + in: path + name: bank_id + required: true + schema: + title: Bank Id + type: string + style: simple + - explode: false + in: header + name: authorization + required: false + schema: + nullable: true + type: string + style: simple + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ObservationScopesResponse' + description: Successful Response + "422": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: List observation scopes + tags: + - Memory /v1/default/banks/{bank_id}/consolidation/recover: post: description: Reset all memories that were permanently marked as failed during @@ -6519,6 +6561,7 @@ components: - all - any_strict - all_strict + - exact nullable: true type: string tag_groups: @@ -6607,6 +6650,7 @@ components: - all - any_strict - all_strict + - exact nullable: true type: string tag_groups: @@ -6624,6 +6668,46 @@ components: nullable: true type: integer title: MentalModelTrigger + ObservationScope: + description: "A distinct observation scope: an exact tag set plus its observation\ + \ count." + properties: + tags: + description: The exact tag set defining this scope (normalized order). Empty + list is the global/untagged scope. + items: + type: string + type: array + count: + description: Number of observations that live under this scope + title: Count + type: integer + required: + - count + - tags + title: ObservationScope + ObservationScopesResponse: + description: Response model for the observation scopes enumeration endpoint. + example: + scopes: + - count: 12 + tags: + - user:alice + - count: 4 + tags: + - user:alice + - project:apollo + - count: 2 + tags: [] + properties: + scopes: + description: "Distinct observation scopes, most populous first" + items: + $ref: '#/components/schemas/ObservationScope' + type: array + required: + - scopes + title: ObservationScopesResponse OperationProgress: description: |- Last-known progress snapshot for a long-running async operation. @@ -6859,6 +6943,7 @@ components: - all - any_strict - all_strict + - exact title: Tags Match type: string tag_groups: @@ -7181,6 +7266,7 @@ components: - all - any_strict - all_strict + - exact title: Tags Match type: string tag_groups: @@ -7482,6 +7568,7 @@ components: - all - any_strict - all_strict + - exact title: Match type: string required: diff --git a/hindsight-clients/go/api_memory.go b/hindsight-clients/go/api_memory.go index 97d2de83e2..920406bbca 100644 --- a/hindsight-clients/go/api_memory.go +++ b/hindsight-clients/go/api_memory.go @@ -924,6 +924,128 @@ func (a *MemoryAPIService) ListMemoriesExecute(r ApiListMemoriesRequest) (*ListM return localVarReturnValue, localVarHTTPResponse, nil } +type ApiListObservationScopesRequest struct { + ctx context.Context + ApiService *MemoryAPIService + bankId string + authorization *string +} + +func (r ApiListObservationScopesRequest) Authorization(authorization string) ApiListObservationScopesRequest { + r.authorization = &authorization + return r +} + +func (r ApiListObservationScopesRequest) Execute() (*ObservationScopesResponse, *http.Response, error) { + return r.ApiService.ListObservationScopesExecute(r) +} + +/* +ListObservationScopes List observation scopes + +Enumerate the distinct scopes across a bank's observations. Each observation lives under a scope: the exact set of tags it was consolidated with. Returns every distinct scope (tag order normalized) with the number of observations in it; the empty tag list is the global/untagged scope. Use a returned scope with the graph endpoint (tags= & tags_match=exact) to filter observations to exactly that scope. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param bankId + @return ApiListObservationScopesRequest +*/ +func (a *MemoryAPIService) ListObservationScopes(ctx context.Context, bankId string) ApiListObservationScopesRequest { + return ApiListObservationScopesRequest{ + ApiService: a, + ctx: ctx, + bankId: bankId, + } +} + +// Execute executes the request +// @return ObservationScopesResponse +func (a *MemoryAPIService) ListObservationScopesExecute(r ApiListObservationScopesRequest) (*ObservationScopesResponse, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *ObservationScopesResponse + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "MemoryAPIService.ListObservationScopes") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/v1/default/banks/{bank_id}/observations/scopes" + localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + if r.authorization != nil { + parameterAddToHeaderOrQuery(localVarHeaderParams, "authorization", r.authorization, "simple", "") + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 422 { + var v HTTPValidationError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} + type ApiListTagsRequest struct { ctx context.Context ApiService *MemoryAPIService diff --git a/hindsight-clients/go/model_observation_scope.go b/hindsight-clients/go/model_observation_scope.go new file mode 100644 index 0000000000..6e2b905b71 --- /dev/null +++ b/hindsight-clients/go/model_observation_scope.go @@ -0,0 +1,188 @@ +/* +Hindsight HTTP API + +HTTP API for Hindsight + +API version: 0.8.1 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package hindsight + +import ( + "encoding/json" + "bytes" + "fmt" +) + +// checks if the ObservationScope type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &ObservationScope{} + +// ObservationScope A distinct observation scope: an exact tag set plus its observation count. +type ObservationScope struct { + // The exact tag set defining this scope (normalized order). Empty list is the global/untagged scope. + Tags []string `json:"tags"` + // Number of observations that live under this scope + Count int32 `json:"count"` +} + +type _ObservationScope ObservationScope + +// NewObservationScope instantiates a new ObservationScope object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewObservationScope(tags []string, count int32) *ObservationScope { + this := ObservationScope{} + this.Tags = tags + this.Count = count + return &this +} + +// NewObservationScopeWithDefaults instantiates a new ObservationScope object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewObservationScopeWithDefaults() *ObservationScope { + this := ObservationScope{} + return &this +} + +// GetTags returns the Tags field value +func (o *ObservationScope) GetTags() []string { + if o == nil { + var ret []string + return ret + } + + return o.Tags +} + +// GetTagsOk returns a tuple with the Tags field value +// and a boolean to check if the value has been set. +func (o *ObservationScope) GetTagsOk() ([]string, bool) { + if o == nil { + return nil, false + } + return o.Tags, true +} + +// SetTags sets field value +func (o *ObservationScope) SetTags(v []string) { + o.Tags = v +} + +// GetCount returns the Count field value +func (o *ObservationScope) GetCount() int32 { + if o == nil { + var ret int32 + return ret + } + + return o.Count +} + +// GetCountOk returns a tuple with the Count field value +// and a boolean to check if the value has been set. +func (o *ObservationScope) GetCountOk() (*int32, bool) { + if o == nil { + return nil, false + } + return &o.Count, true +} + +// SetCount sets field value +func (o *ObservationScope) SetCount(v int32) { + o.Count = v +} + +func (o ObservationScope) MarshalJSON() ([]byte, error) { + toSerialize,err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o ObservationScope) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["tags"] = o.Tags + toSerialize["count"] = o.Count + return toSerialize, nil +} + +func (o *ObservationScope) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "tags", + "count", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err; + } + + for _, requiredProperty := range(requiredProperties) { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varObservationScope := _ObservationScope{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varObservationScope) + + if err != nil { + return err + } + + *o = ObservationScope(varObservationScope) + + return err +} + +type NullableObservationScope struct { + value *ObservationScope + isSet bool +} + +func (v NullableObservationScope) Get() *ObservationScope { + return v.value +} + +func (v *NullableObservationScope) Set(val *ObservationScope) { + v.value = val + v.isSet = true +} + +func (v NullableObservationScope) IsSet() bool { + return v.isSet +} + +func (v *NullableObservationScope) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableObservationScope(val *ObservationScope) *NullableObservationScope { + return &NullableObservationScope{value: val, isSet: true} +} + +func (v NullableObservationScope) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableObservationScope) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} + + diff --git a/hindsight-clients/go/model_observation_scopes_response.go b/hindsight-clients/go/model_observation_scopes_response.go new file mode 100644 index 0000000000..8299b6a3c0 --- /dev/null +++ b/hindsight-clients/go/model_observation_scopes_response.go @@ -0,0 +1,159 @@ +/* +Hindsight HTTP API + +HTTP API for Hindsight + +API version: 0.8.1 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package hindsight + +import ( + "encoding/json" + "bytes" + "fmt" +) + +// checks if the ObservationScopesResponse type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &ObservationScopesResponse{} + +// ObservationScopesResponse Response model for the observation scopes enumeration endpoint. +type ObservationScopesResponse struct { + // Distinct observation scopes, most populous first + Scopes []ObservationScope `json:"scopes"` +} + +type _ObservationScopesResponse ObservationScopesResponse + +// NewObservationScopesResponse instantiates a new ObservationScopesResponse object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewObservationScopesResponse(scopes []ObservationScope) *ObservationScopesResponse { + this := ObservationScopesResponse{} + this.Scopes = scopes + return &this +} + +// NewObservationScopesResponseWithDefaults instantiates a new ObservationScopesResponse object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewObservationScopesResponseWithDefaults() *ObservationScopesResponse { + this := ObservationScopesResponse{} + return &this +} + +// GetScopes returns the Scopes field value +func (o *ObservationScopesResponse) GetScopes() []ObservationScope { + if o == nil { + var ret []ObservationScope + return ret + } + + return o.Scopes +} + +// GetScopesOk returns a tuple with the Scopes field value +// and a boolean to check if the value has been set. +func (o *ObservationScopesResponse) GetScopesOk() ([]ObservationScope, bool) { + if o == nil { + return nil, false + } + return o.Scopes, true +} + +// SetScopes sets field value +func (o *ObservationScopesResponse) SetScopes(v []ObservationScope) { + o.Scopes = v +} + +func (o ObservationScopesResponse) MarshalJSON() ([]byte, error) { + toSerialize,err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o ObservationScopesResponse) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["scopes"] = o.Scopes + return toSerialize, nil +} + +func (o *ObservationScopesResponse) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "scopes", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err; + } + + for _, requiredProperty := range(requiredProperties) { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varObservationScopesResponse := _ObservationScopesResponse{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varObservationScopesResponse) + + if err != nil { + return err + } + + *o = ObservationScopesResponse(varObservationScopesResponse) + + return err +} + +type NullableObservationScopesResponse struct { + value *ObservationScopesResponse + isSet bool +} + +func (v NullableObservationScopesResponse) Get() *ObservationScopesResponse { + return v.value +} + +func (v *NullableObservationScopesResponse) Set(val *ObservationScopesResponse) { + v.value = val + v.isSet = true +} + +func (v NullableObservationScopesResponse) IsSet() bool { + return v.isSet +} + +func (v *NullableObservationScopesResponse) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableObservationScopesResponse(val *ObservationScopesResponse) *NullableObservationScopesResponse { + return &NullableObservationScopesResponse{value: val, isSet: true} +} + +func (v NullableObservationScopesResponse) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableObservationScopesResponse) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} + + diff --git a/hindsight-clients/python/.openapi-generator/FILES b/hindsight-clients/python/.openapi-generator/FILES index 3a6c323302..d34d249dbc 100644 --- a/hindsight-clients/python/.openapi-generator/FILES +++ b/hindsight-clients/python/.openapi-generator/FILES @@ -94,7 +94,9 @@ hindsight_client_api/models/mental_model_trigger_output.py hindsight_client_api/models/mental_model_trigger_output_tag_groups_inner.py hindsight_client_api/models/model_not.py hindsight_client_api/models/not1.py +hindsight_client_api/models/observation_scope.py hindsight_client_api/models/observation_scopes.py +hindsight_client_api/models/observation_scopes_response.py hindsight_client_api/models/operation_progress.py hindsight_client_api/models/operation_response.py hindsight_client_api/models/operation_status_response.py diff --git a/hindsight-clients/python/hindsight_client_api/__init__.py b/hindsight-clients/python/hindsight_client_api/__init__.py index 091cb38fef..7edef9c0f9 100644 --- a/hindsight-clients/python/hindsight_client_api/__init__.py +++ b/hindsight-clients/python/hindsight_client_api/__init__.py @@ -118,7 +118,9 @@ from hindsight_client_api.models.mental_model_trigger_output_tag_groups_inner import MentalModelTriggerOutputTagGroupsInner from hindsight_client_api.models.model_not import ModelNot from hindsight_client_api.models.not1 import Not1 +from hindsight_client_api.models.observation_scope import ObservationScope from hindsight_client_api.models.observation_scopes import ObservationScopes +from hindsight_client_api.models.observation_scopes_response import ObservationScopesResponse from hindsight_client_api.models.operation_progress import OperationProgress from hindsight_client_api.models.operation_response import OperationResponse from hindsight_client_api.models.operation_status_response import OperationStatusResponse diff --git a/hindsight-clients/python/hindsight_client_api/api/memory_api.py b/hindsight-clients/python/hindsight_client_api/api/memory_api.py index 7609f14116..d5a85e47a4 100644 --- a/hindsight-clients/python/hindsight_client_api/api/memory_api.py +++ b/hindsight-clients/python/hindsight_client_api/api/memory_api.py @@ -24,6 +24,7 @@ from hindsight_client_api.models.graph_data_response import GraphDataResponse from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse from hindsight_client_api.models.list_tags_response import ListTagsResponse +from hindsight_client_api.models.observation_scopes_response import ObservationScopesResponse from hindsight_client_api.models.recall_request import RecallRequest from hindsight_client_api.models.recall_response import RecallResponse from hindsight_client_api.models.reflect_request import ReflectRequest @@ -2019,6 +2020,284 @@ def _list_memories_serialize( + @validate_call + async def list_observation_scopes( + self, + bank_id: StrictStr, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ObservationScopesResponse: + """List observation scopes + + Enumerate the distinct scopes across a bank's observations. Each observation lives under a scope: the exact set of tags it was consolidated with. Returns every distinct scope (tag order normalized) with the number of observations in it; the empty tag list is the global/untagged scope. Use a returned scope with the graph endpoint (tags= & tags_match=exact) to filter observations to exactly that scope. + + :param bank_id: (required) + :type bank_id: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._list_observation_scopes_serialize( + bank_id=bank_id, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "ObservationScopesResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def list_observation_scopes_with_http_info( + self, + bank_id: StrictStr, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[ObservationScopesResponse]: + """List observation scopes + + Enumerate the distinct scopes across a bank's observations. Each observation lives under a scope: the exact set of tags it was consolidated with. Returns every distinct scope (tag order normalized) with the number of observations in it; the empty tag list is the global/untagged scope. Use a returned scope with the graph endpoint (tags= & tags_match=exact) to filter observations to exactly that scope. + + :param bank_id: (required) + :type bank_id: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._list_observation_scopes_serialize( + bank_id=bank_id, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "ObservationScopesResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def list_observation_scopes_without_preload_content( + self, + bank_id: StrictStr, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """List observation scopes + + Enumerate the distinct scopes across a bank's observations. Each observation lives under a scope: the exact set of tags it was consolidated with. Returns every distinct scope (tag order normalized) with the number of observations in it; the empty tag list is the global/untagged scope. Use a returned scope with the graph endpoint (tags= & tags_match=exact) to filter observations to exactly that scope. + + :param bank_id: (required) + :type bank_id: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._list_observation_scopes_serialize( + bank_id=bank_id, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "ObservationScopesResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _list_observation_scopes_serialize( + self, + bank_id, + authorization, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + if bank_id is not None: + _path_params['bank_id'] = bank_id + # process the query parameters + # process the header parameters + if authorization is not None: + _header_params['authorization'] = authorization + # process the form parameters + # process the body parameter + + + # set the HTTP header `Accept` + if 'Accept' not in _header_params: + _header_params['Accept'] = self.api_client.select_header_accept( + [ + 'application/json' + ] + ) + + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='GET', + resource_path='/v1/default/banks/{bank_id}/observations/scopes', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + + + @validate_call async def list_tags( self, diff --git a/hindsight-clients/python/hindsight_client_api/models/__init__.py b/hindsight-clients/python/hindsight_client_api/models/__init__.py index 64fd7ea5c3..d0ebaa2a59 100644 --- a/hindsight-clients/python/hindsight_client_api/models/__init__.py +++ b/hindsight-clients/python/hindsight_client_api/models/__init__.py @@ -88,7 +88,9 @@ from hindsight_client_api.models.mental_model_trigger_output_tag_groups_inner import MentalModelTriggerOutputTagGroupsInner from hindsight_client_api.models.model_not import ModelNot from hindsight_client_api.models.not1 import Not1 +from hindsight_client_api.models.observation_scope import ObservationScope from hindsight_client_api.models.observation_scopes import ObservationScopes +from hindsight_client_api.models.observation_scopes_response import ObservationScopesResponse from hindsight_client_api.models.operation_progress import OperationProgress from hindsight_client_api.models.operation_response import OperationResponse from hindsight_client_api.models.operation_status_response import OperationStatusResponse diff --git a/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_input.py b/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_input.py index d742eb3148..1149032548 100644 --- a/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_input.py +++ b/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_input.py @@ -66,8 +66,8 @@ def tags_match_validate_enum(cls, value): if value is None: return value - if value not in set(['any', 'all', 'any_strict', 'all_strict']): - raise ValueError("must be one of enum values ('any', 'all', 'any_strict', 'all_strict')") + if value not in set(['any', 'all', 'any_strict', 'all_strict', 'exact']): + raise ValueError("must be one of enum values ('any', 'all', 'any_strict', 'all_strict', 'exact')") return value model_config = ConfigDict( diff --git a/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_output.py b/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_output.py index 47ce1525d6..5d3e0229e6 100644 --- a/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_output.py +++ b/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_output.py @@ -66,8 +66,8 @@ def tags_match_validate_enum(cls, value): if value is None: return value - if value not in set(['any', 'all', 'any_strict', 'all_strict']): - raise ValueError("must be one of enum values ('any', 'all', 'any_strict', 'all_strict')") + if value not in set(['any', 'all', 'any_strict', 'all_strict', 'exact']): + raise ValueError("must be one of enum values ('any', 'all', 'any_strict', 'all_strict', 'exact')") return value model_config = ConfigDict( diff --git a/hindsight-clients/python/hindsight_client_api/models/observation_scope.py b/hindsight-clients/python/hindsight_client_api/models/observation_scope.py new file mode 100644 index 0000000000..88aee52917 --- /dev/null +++ b/hindsight-clients/python/hindsight_client_api/models/observation_scope.py @@ -0,0 +1,89 @@ +# coding: utf-8 + +""" + Hindsight HTTP API + + HTTP API for Hindsight + + The version of the OpenAPI document: 0.8.1 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr +from typing import Any, ClassVar, Dict, List +from typing import Optional, Set +from typing_extensions import Self + +class ObservationScope(BaseModel): + """ + A distinct observation scope: an exact tag set plus its observation count. + """ # noqa: E501 + tags: List[StrictStr] = Field(description="The exact tag set defining this scope (normalized order). Empty list is the global/untagged scope.") + count: StrictInt = Field(description="Number of observations that live under this scope") + __properties: ClassVar[List[str]] = ["tags", "count"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of ObservationScope from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([ + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of ObservationScope from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "tags": obj.get("tags"), + "count": obj.get("count") + }) + return _obj + + diff --git a/hindsight-clients/python/hindsight_client_api/models/observation_scopes_response.py b/hindsight-clients/python/hindsight_client_api/models/observation_scopes_response.py new file mode 100644 index 0000000000..e1c5ba6b08 --- /dev/null +++ b/hindsight-clients/python/hindsight_client_api/models/observation_scopes_response.py @@ -0,0 +1,95 @@ +# coding: utf-8 + +""" + Hindsight HTTP API + + HTTP API for Hindsight + + The version of the OpenAPI document: 0.8.1 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field +from typing import Any, ClassVar, Dict, List +from hindsight_client_api.models.observation_scope import ObservationScope +from typing import Optional, Set +from typing_extensions import Self + +class ObservationScopesResponse(BaseModel): + """ + Response model for the observation scopes enumeration endpoint. + """ # noqa: E501 + scopes: List[ObservationScope] = Field(description="Distinct observation scopes, most populous first") + __properties: ClassVar[List[str]] = ["scopes"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of ObservationScopesResponse from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([ + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # override the default output from pydantic by calling `to_dict()` of each item in scopes (list) + _items = [] + if self.scopes: + for _item_scopes in self.scopes: + if _item_scopes: + _items.append(_item_scopes.to_dict()) + _dict['scopes'] = _items + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of ObservationScopesResponse from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "scopes": [ObservationScope.from_dict(_item) for _item in obj["scopes"]] if obj.get("scopes") is not None else None + }) + return _obj + + 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 9bf69468a3..8d26e2439b 100644 --- a/hindsight-clients/python/hindsight_client_api/models/recall_request.py +++ b/hindsight-clients/python/hindsight_client_api/models/recall_request.py @@ -47,8 +47,8 @@ def tags_match_validate_enum(cls, value): if value is None: return value - if value not in set(['any', 'all', 'any_strict', 'all_strict']): - raise ValueError("must be one of enum values ('any', 'all', 'any_strict', 'all_strict')") + if value not in set(['any', 'all', 'any_strict', 'all_strict', 'exact']): + raise ValueError("must be one of enum values ('any', 'all', 'any_strict', 'all_strict', 'exact')") return value model_config = ConfigDict( diff --git a/hindsight-clients/python/hindsight_client_api/models/reflect_request.py b/hindsight-clients/python/hindsight_client_api/models/reflect_request.py index 4134bdd9db..d5874a78a0 100644 --- a/hindsight-clients/python/hindsight_client_api/models/reflect_request.py +++ b/hindsight-clients/python/hindsight_client_api/models/reflect_request.py @@ -49,8 +49,8 @@ def tags_match_validate_enum(cls, value): if value is None: return value - if value not in set(['any', 'all', 'any_strict', 'all_strict']): - raise ValueError("must be one of enum values ('any', 'all', 'any_strict', 'all_strict')") + if value not in set(['any', 'all', 'any_strict', 'all_strict', 'exact']): + raise ValueError("must be one of enum values ('any', 'all', 'any_strict', 'all_strict', 'exact')") return value @field_validator('fact_types') diff --git a/hindsight-clients/python/hindsight_client_api/models/tag_group_leaf.py b/hindsight-clients/python/hindsight_client_api/models/tag_group_leaf.py index 306ad69726..e72f05d0f5 100644 --- a/hindsight-clients/python/hindsight_client_api/models/tag_group_leaf.py +++ b/hindsight-clients/python/hindsight_client_api/models/tag_group_leaf.py @@ -36,8 +36,8 @@ def match_validate_enum(cls, value): if value is None: return value - if value not in set(['any', 'all', 'any_strict', 'all_strict']): - raise ValueError("must be one of enum values ('any', 'all', 'any_strict', 'all_strict')") + if value not in set(['any', 'all', 'any_strict', 'all_strict', 'exact']): + raise ValueError("must be one of enum values ('any', 'all', 'any_strict', 'all_strict', 'exact')") return value model_config = ConfigDict( diff --git a/hindsight-clients/typescript/generated/sdk.gen.ts b/hindsight-clients/typescript/generated/sdk.gen.ts index c9b327ba94..af5a744065 100644 --- a/hindsight-clients/typescript/generated/sdk.gen.ts +++ b/hindsight-clients/typescript/generated/sdk.gen.ts @@ -149,6 +149,9 @@ import type { ListMentalModelsData, ListMentalModelsErrors, ListMentalModelsResponses, + ListObservationScopesData, + ListObservationScopesErrors, + ListObservationScopesResponses, ListOperationsData, ListOperationsErrors, ListOperationsResponses, @@ -1072,6 +1075,20 @@ export const clearObservations = ( ThrowOnError >({ url: "/v1/default/banks/{bank_id}/observations", ...options }); +/** + * List observation scopes + * + * Enumerate the distinct scopes across a bank's observations. Each observation lives under a scope: the exact set of tags it was consolidated with. Returns every distinct scope (tag order normalized) with the number of observations in it; the empty tag list is the global/untagged scope. Use a returned scope with the graph endpoint (tags= & tags_match=exact) to filter observations to exactly that scope. + */ +export const listObservationScopes = ( + options: Options +) => + (options.client ?? client).get< + ListObservationScopesResponses, + ListObservationScopesErrors, + ThrowOnError + >({ url: "/v1/default/banks/{bank_id}/observations/scopes", ...options }); + /** * Recover failed consolidation * diff --git a/hindsight-clients/typescript/generated/types.gen.ts b/hindsight-clients/typescript/generated/types.gen.ts index 1cf79e6944..7fb4832b43 100644 --- a/hindsight-clients/typescript/generated/types.gen.ts +++ b/hindsight-clients/typescript/generated/types.gen.ts @@ -2384,7 +2384,7 @@ export type MentalModelTriggerInput = { * * Override how the model's tags filter memories during refresh. If not set, defaults to 'all_strict' when the model has tags (security isolation) or 'any' when the model has no tags. Set to 'any' to include untagged memories alongside tagged ones during refresh. */ - tags_match?: "any" | "all" | "any_strict" | "all_strict" | null; + tags_match?: "any" | "all" | "any_strict" | "all_strict" | "exact" | null; /** * Tag Groups * @@ -2452,7 +2452,7 @@ export type MentalModelTriggerOutput = { * * Override how the model's tags filter memories during refresh. If not set, defaults to 'all_strict' when the model has tags (security isolation) or 'any' when the model has no tags. Set to 'any' to include untagged memories alongside tagged ones during refresh. */ - tags_match?: "any" | "all" | "any_strict" | "all_strict" | null; + tags_match?: "any" | "all" | "any_strict" | "all_strict" | "exact" | null; /** * Tag Groups * @@ -2481,6 +2481,40 @@ export type MentalModelTriggerOutput = { recall_chunks_max_tokens?: number | null; }; +/** + * ObservationScope + * + * A distinct observation scope: an exact tag set plus its observation count. + */ +export type ObservationScope = { + /** + * Tags + * + * The exact tag set defining this scope (normalized order). Empty list is the global/untagged scope. + */ + tags: Array; + /** + * Count + * + * Number of observations that live under this scope + */ + count: number; +}; + +/** + * ObservationScopesResponse + * + * Response model for the observation scopes enumeration endpoint. + */ +export type ObservationScopesResponse = { + /** + * Scopes + * + * Distinct observation scopes, most populous first + */ + scopes: Array; +}; + /** * OperationProgress * @@ -2733,7 +2767,7 @@ export type RecallRequest = { * * How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged), 'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged). */ - tags_match?: "any" | "all" | "any_strict" | "all_strict"; + tags_match?: "any" | "all" | "any_strict" | "all_strict" | "exact"; /** * Tag Groups * @@ -3054,7 +3088,7 @@ export type ReflectRequest = { * * How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged), 'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged). */ - tags_match?: "any" | "all" | "any_strict" | "all_strict"; + tags_match?: "any" | "all" | "any_strict" | "all_strict" | "exact"; /** * Tag Groups * @@ -3342,7 +3376,7 @@ export type TagGroupLeaf = { /** * Match */ - match?: "any" | "all" | "any_strict" | "all_strict"; + match?: "any" | "all" | "any_strict" | "all_strict" | "exact"; }; /** @@ -6149,6 +6183,44 @@ export type ClearObservationsResponses = { export type ClearObservationsResponse = ClearObservationsResponses[keyof ClearObservationsResponses]; +export type ListObservationScopesData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + }; + path: { + /** + * Bank Id + */ + bank_id: string; + }; + query?: never; + url: "/v1/default/banks/{bank_id}/observations/scopes"; +}; + +export type ListObservationScopesErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ListObservationScopesError = + ListObservationScopesErrors[keyof ListObservationScopesErrors]; + +export type ListObservationScopesResponses = { + /** + * Successful Response + */ + 200: ObservationScopesResponse; +}; + +export type ListObservationScopesResponse = + ListObservationScopesResponses[keyof ListObservationScopesResponses]; + export type RecoverConsolidationData = { body?: never; headers?: { diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/observations/scopes/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/observations/scopes/route.ts new file mode 100644 index 0000000000..6569c73169 --- /dev/null +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/observations/scopes/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; +import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; +import { DATAPLANE_URL, getDataplaneHeaders } from "@/lib/hindsight-client"; + +export async function GET(request: Request, { params }: { params: Promise<{ bankId: string }> }) { + try { + const { bankId } = await params; + + if (!bankId) { + return NextResponse.json( + localizeApiErrorPayload(request, { + error: "bank_id is required", + errorKey: "api.errors.validation.bankIdRequired", + }), + { status: 400 } + ); + } + + const response = await fetch( + `${DATAPLANE_URL}/v1/default/banks/${bankId}/observations/scopes`, + { headers: getDataplaneHeaders() } + ); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: response.statusText })); + return NextResponse.json(error, { status: response.status }); + } + + const data = await response.json(); + return NextResponse.json(data, { status: 200 }); + } catch (error) { + console.error("Error listing observation scopes:", error); + return NextResponse.json( + localizeApiErrorPayload(request, { + error: "Failed to list observation scopes", + errorKey: "api.errors.observations.list", + }), + { status: 500 } + ); + } +} diff --git a/hindsight-control-plane/src/app/api/graph/route.ts b/hindsight-control-plane/src/app/api/graph/route.ts index 81d2468c40..95ecdf6359 100644 --- a/hindsight-control-plane/src/app/api/graph/route.ts +++ b/hindsight-control-plane/src/app/api/graph/route.ts @@ -27,7 +27,16 @@ export async function GET(request: NextRequest) { if (q) params.append("q", q); const tags = searchParams.getAll("tags"); for (const tag of tags) params.append("tags", tag); - if (tags.length > 0) params.append("tags_match", "all_strict"); + // Forward an explicit match mode if the caller set one (e.g. "exact" for + // observation-scope filtering, which also drives the global/untagged scope + // when no tags are present). Otherwise default to all_strict when tags are + // present (tag isolation), matching prior behavior. + const tagsMatch = searchParams.get("tags_match"); + if (tagsMatch) { + params.append("tags_match", tagsMatch); + } else if (tags.length > 0) { + params.append("tags_match", "all_strict"); + } const documentId = searchParams.get("document_id"); if (documentId) params.append("document_id", documentId); const chunkId = searchParams.get("chunk_id"); diff --git a/hindsight-control-plane/src/components/data-view.tsx b/hindsight-control-plane/src/components/data-view.tsx index 9cc48d4c51..af38dd584b 100644 --- a/hindsight-control-plane/src/components/data-view.tsx +++ b/hindsight-control-plane/src/components/data-view.tsx @@ -47,6 +47,7 @@ import { MemoryDetailModal } from "./memory-detail-modal"; import { Graph2D, convertHindsightGraphData, GraphNode } from "./graph-2d"; import { Constellation } from "./constellation"; import { TagFilterInput } from "./tag-filter-input"; +import { ObservationScopeFilter, ObservationScope } from "./observation-scope-filter"; import { ScatterChart, Plus, FileText } from "lucide-react"; type FactType = "world" | "experience" | "observation"; @@ -76,6 +77,11 @@ export function DataView({ const [loading, setLoading] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [tagFilters, setTagFilters] = useState([]); + // Observation scope filtering: the distinct scopes available, and the selected + // one. `null` = all scopes; `[]` = the global (untagged) scope; otherwise an + // exact tag set. Mutually exclusive with the free-form tag filter above. + const [scopes, setScopes] = useState([]); + const [selectedScope, setSelectedScope] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [selectedGraphNode, setSelectedGraphNode] = useState(null); const [modalMemoryId, setModalMemoryId] = useState(null); @@ -133,7 +139,7 @@ export function DataView({ return () => window.removeEventListener("keydown", handleKeyDown); }, [selectedGraphNode]); - const loadData = async (limit?: number, q?: string, tags?: string[]) => { + const loadData = async (limit?: number, q?: string, tags?: string[], tagsMatch?: string) => { if (!currentBank) return; setLoading(true); @@ -144,6 +150,7 @@ export function DataView({ limit: limit ?? fetchLimit, q, tags, + tags_match: tagsMatch, document_id: documentId, chunk_id: chunkId, }); @@ -337,30 +344,63 @@ export function DataView({ // Reset to first page when filters change useEffect(() => { setCurrentPage(1); - }, [tagFilters]); + }, [tagFilters, selectedScope]); + + // Resolve the active tag filter into (tags, tags_match) for the graph query. + // A selected observation scope takes precedence and uses exact set-equality + // matching (so scope [a] excludes [a, b]); otherwise the free-form tag filter + // uses the default contains semantics. `null` scope means "no scope filter". + const resolveTagQuery = useCallback((): { tags?: string[]; match?: string } => { + if (selectedScope !== null) { + return { tags: selectedScope, match: "exact" }; + } + return { tags: tagFilters.length > 0 ? tagFilters : undefined }; + }, [selectedScope, tagFilters]); // Trigger text search on Enter key const executeSearch = () => { if (currentBank) { setCurrentPage(1); - loadData(undefined, searchQuery || undefined, tagFilters.length > 0 ? tagFilters : undefined); + const { tags, match } = resolveTagQuery(); + loadData(undefined, searchQuery || undefined, tags, match); } }; - // Trigger server-side reload immediately when tag filters change + // Trigger server-side reload immediately when the tag filter or scope changes useEffect(() => { if (currentBank) { - loadData(undefined, searchQuery || undefined, tagFilters.length > 0 ? tagFilters : undefined); + const { tags, match } = resolveTagQuery(); + loadData(undefined, searchQuery || undefined, tags, match); } - }, [tagFilters]); + }, [tagFilters, selectedScope]); - // Auto-load data when component mounts or factType/currentBank changes + // Auto-load data when component mounts or factType/currentBank changes. + // Clearing the scope here resets it before the filter effect above re-runs. useEffect(() => { + setSelectedScope(null); if (currentBank) { loadData(); } }, [factType, currentBank, documentId, chunkId]); + // Load the available observation scopes for the scope filter dropdown. + const loadScopes = useCallback(async () => { + if (!currentBank || factType !== "observation") { + setScopes([]); + return; + } + try { + const resp = await client.listObservationScopes(currentBank); + setScopes(resp.scopes ?? []); + } catch { + setScopes([]); + } + }, [currentBank, factType]); + + useEffect(() => { + loadScopes(); + }, [loadScopes]); + // Enforce 50 node limit to prevent UI instability, default to 20 or max whichever is smaller useEffect(() => { if (data && maxNodes === undefined) { @@ -430,8 +470,28 @@ export function DataView({ className="pl-8 h-9" /> - {/* Tag input */} - + {/* Tag input. Setting a tag filter clears any selected scope + so the two filters never fight over the same query. */} + { + if (next.length > 0) setSelectedScope(null); + setTagFilters(next); + }} + bankId={currentBank} + /> + {/* Observation scope filter. Selecting a scope clears the + free-form tag filter (mutually exclusive). */} + {factType === "observation" && scopes.length > 0 && ( + { + if (scope !== null) setTagFilters([]); + setSelectedScope(scope); + }} + /> + )} )} diff --git a/hindsight-control-plane/src/components/observation-scope-filter.tsx b/hindsight-control-plane/src/components/observation-scope-filter.tsx new file mode 100644 index 0000000000..e4b222373f --- /dev/null +++ b/hindsight-control-plane/src/components/observation-scope-filter.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { Layers } from "lucide-react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +export interface ObservationScope { + tags: string[]; + count: number; +} + +interface ObservationScopeFilterProps { + scopes: ObservationScope[]; + /** Selected scope: a tag set (possibly empty = global), or null for "all scopes". */ + value: string[] | null; + onChange: (scope: string[] | null) => void; +} + +const ALL_VALUE = "__all__"; + +/** Encode a scope's tag set into a stable Select value (JSON of the tags). */ +function scopeValue(tags: string[]): string { + return JSON.stringify(tags); +} + +/** Render a scope's tag set as inline pills, or the "global" label when empty. */ +function ScopeTags({ tags, globalLabel }: { tags: string[]; globalLabel: string }) { + if (tags.length === 0) { + return {globalLabel}; + } + return ( + + {tags.map((tag) => ( + + # + {tag} + + ))} + + ); +} + +/** + * Dropdown that enumerates every distinct observation scope (exact tag set) in + * the bank and lets the user filter observations down to one scope. The empty + * tag set is the global/untagged scope; "All scopes" clears the filter. + */ +export function ObservationScopeFilter({ scopes, value, onChange }: ObservationScopeFilterProps) { + const t = useTranslations("dataView"); + + const selectValue = value === null ? ALL_VALUE : scopeValue(value); + + const handleChange = (next: string) => { + if (next === ALL_VALUE) { + onChange(null); + return; + } + onChange(JSON.parse(next) as string[]); + }; + + return ( + + ); +} diff --git a/hindsight-control-plane/src/lib/api.ts b/hindsight-control-plane/src/lib/api.ts index 47dae77223..11fe803551 100644 --- a/hindsight-control-plane/src/lib/api.ts +++ b/hindsight-control-plane/src/lib/api.ts @@ -441,6 +441,7 @@ export class ControlPlaneClient { limit?: number; q?: string; tags?: string[]; + tags_match?: string; document_id?: string; chunk_id?: string; }) { @@ -452,6 +453,10 @@ export class ControlPlaneClient { if (params.tags && params.tags.length > 0) { params.tags.forEach((tag) => queryParams.append("tags", tag)); } + // Forward the match mode explicitly (e.g. "exact" for observation-scope + // filtering). With tags_match=exact and no tags, the dataplane treats it as + // the global/untagged scope. + if (params.tags_match) queryParams.append("tags_match", params.tags_match); if (params.document_id) queryParams.append("document_id", params.document_id); if (params.chunk_id) queryParams.append("chunk_id", params.chunk_id); return this.fetchApi(`/api/graph?${queryParams}`); @@ -1139,6 +1144,19 @@ export class ControlPlaneClient { }>(bankApi(bankId, `/observations/${encodeURIComponent(observationId)}`)); } + /** + * List the distinct observation scopes for a bank. + * + * Each observation lives under a "scope": the exact set of tags it was + * consolidated with. Returns every distinct scope (tag order normalized) with + * the number of observations in it; the empty tag list is the global scope. + */ + async listObservationScopes(bankId: string) { + return this.fetchApi<{ + scopes: Array<{ tags: string[]; count: number }>; + }>(bankApi(bankId, `/observations/scopes`)); + } + // ============= TAGS ============= /** diff --git a/hindsight-control-plane/src/messages/de.json b/hindsight-control-plane/src/messages/de.json index 594c4da4d5..8213fc5e65 100644 --- a/hindsight-control-plane/src/messages/de.json +++ b/hindsight-control-plane/src/messages/de.json @@ -577,7 +577,10 @@ "sourceFactsLabel": "Quellfakten", "errorLoadingData": "Fehler beim Laden der {factType}-Daten", "all": "Alle", - "memoryComposition": "Speicherzusammensetzung" + "memoryComposition": "Speicherzusammensetzung", + "scopeLabel": "Bereich", + "scopeAll": "Alle Bereiche", + "scopeGlobal": "Global (keine Tags)" }, "documentsView": { "loadingDocuments": "Dokumente werden geladen...", diff --git a/hindsight-control-plane/src/messages/en.json b/hindsight-control-plane/src/messages/en.json index 1384c85f06..29a507dbfc 100644 --- a/hindsight-control-plane/src/messages/en.json +++ b/hindsight-control-plane/src/messages/en.json @@ -577,7 +577,10 @@ "sourceFactsLabel": "source facts", "errorLoadingData": "Error loading {factType} data", "all": "All", - "memoryComposition": "Memory composition" + "memoryComposition": "Memory composition", + "scopeLabel": "Scope", + "scopeAll": "All scopes", + "scopeGlobal": "Global (no tags)" }, "documentsView": { "loadingDocuments": "Loading documents...", diff --git a/hindsight-control-plane/src/messages/es.json b/hindsight-control-plane/src/messages/es.json index 9fa924b992..ce52a2eed3 100644 --- a/hindsight-control-plane/src/messages/es.json +++ b/hindsight-control-plane/src/messages/es.json @@ -577,7 +577,10 @@ "sourceFactsLabel": "hechos fuente", "errorLoadingData": "Error al cargar datos de {factType}", "all": "Todos", - "memoryComposition": "Composición de memorias" + "memoryComposition": "Composición de memorias", + "scopeLabel": "Ámbito", + "scopeAll": "Todos los ámbitos", + "scopeGlobal": "Global (sin etiquetas)" }, "documentsView": { "loadingDocuments": "Cargando documentos...", diff --git a/hindsight-control-plane/src/messages/fr.json b/hindsight-control-plane/src/messages/fr.json index c5d31dbc3d..bbfd053dfc 100644 --- a/hindsight-control-plane/src/messages/fr.json +++ b/hindsight-control-plane/src/messages/fr.json @@ -577,7 +577,10 @@ "sourceFactsLabel": "faits sources", "errorLoadingData": "Erreur lors du chargement des données {factType}", "all": "Tous", - "memoryComposition": "Composition de la mémoire" + "memoryComposition": "Composition de la mémoire", + "scopeLabel": "Portée", + "scopeAll": "Toutes les portées", + "scopeGlobal": "Global (sans tags)" }, "documentsView": { "loadingDocuments": "Chargement des documents...", diff --git a/hindsight-control-plane/src/messages/ja.json b/hindsight-control-plane/src/messages/ja.json index 1ec463f1be..2259199dea 100644 --- a/hindsight-control-plane/src/messages/ja.json +++ b/hindsight-control-plane/src/messages/ja.json @@ -577,7 +577,10 @@ "sourceFactsLabel": "ソースファクト", "errorLoadingData": "{factType}データの読み込みエラー", "all": "すべて", - "memoryComposition": "メモリ構成" + "memoryComposition": "メモリ構成", + "scopeLabel": "スコープ", + "scopeAll": "すべてのスコープ", + "scopeGlobal": "グローバル(タグなし)" }, "documentsView": { "loadingDocuments": "ドキュメントを読み込み中...", diff --git a/hindsight-control-plane/src/messages/ko.json b/hindsight-control-plane/src/messages/ko.json index 6bc13cdc00..eff595c42d 100644 --- a/hindsight-control-plane/src/messages/ko.json +++ b/hindsight-control-plane/src/messages/ko.json @@ -577,7 +577,10 @@ "sourceFactsLabel": "소스 사실", "errorLoadingData": "{factType} 데이터 불러오기 오류", "all": "전체", - "memoryComposition": "메모리 구성" + "memoryComposition": "메모리 구성", + "scopeLabel": "범위", + "scopeAll": "모든 범위", + "scopeGlobal": "전역(태그 없음)" }, "documentsView": { "loadingDocuments": "문서 불러오는 중...", diff --git a/hindsight-control-plane/src/messages/pt.json b/hindsight-control-plane/src/messages/pt.json index f5a92d58ad..e45ca98c88 100644 --- a/hindsight-control-plane/src/messages/pt.json +++ b/hindsight-control-plane/src/messages/pt.json @@ -577,7 +577,10 @@ "sourceFactsLabel": "fatos de origem", "errorLoadingData": "Erro ao carregar dados de {factType}", "all": "Todos", - "memoryComposition": "Composição da memória" + "memoryComposition": "Composição da memória", + "scopeLabel": "Escopo", + "scopeAll": "Todos os escopos", + "scopeGlobal": "Global (sem tags)" }, "documentsView": { "loadingDocuments": "Carregando documentos...", diff --git a/hindsight-control-plane/src/messages/yue-Hant.json b/hindsight-control-plane/src/messages/yue-Hant.json index 077fd1c009..b5d1c3d813 100644 --- a/hindsight-control-plane/src/messages/yue-Hant.json +++ b/hindsight-control-plane/src/messages/yue-Hant.json @@ -577,7 +577,10 @@ "sourceFactsLabel": "來源事實", "errorLoadingData": "載入 {factType} 資料時發生錯誤", "all": "全部", - "memoryComposition": "記憶構成" + "memoryComposition": "記憶構成", + "scopeLabel": "範圍", + "scopeAll": "所有範圍", + "scopeGlobal": "全域(冇標籤)" }, "documentsView": { "loadingDocuments": "載入文件中...", diff --git a/hindsight-control-plane/src/messages/zh-CN.json b/hindsight-control-plane/src/messages/zh-CN.json index 70c1a2f89e..cfef4ca811 100644 --- a/hindsight-control-plane/src/messages/zh-CN.json +++ b/hindsight-control-plane/src/messages/zh-CN.json @@ -577,7 +577,10 @@ "sourceFactsLabel": "来源事实", "errorLoadingData": "加载 {factType} 数据出错", "all": "全部", - "memoryComposition": "记忆构成" + "memoryComposition": "记忆构成", + "scopeLabel": "范围", + "scopeAll": "所有范围", + "scopeGlobal": "全局(无标签)" }, "documentsView": { "loadingDocuments": "正在加载文档...", diff --git a/hindsight-control-plane/src/messages/zh-TW.json b/hindsight-control-plane/src/messages/zh-TW.json index 80c07f00a0..a5f81dab24 100644 --- a/hindsight-control-plane/src/messages/zh-TW.json +++ b/hindsight-control-plane/src/messages/zh-TW.json @@ -577,7 +577,10 @@ "sourceFactsLabel": "來源事實", "errorLoadingData": "載入 {factType} 資料時發生錯誤", "all": "全部", - "memoryComposition": "記憶構成" + "memoryComposition": "記憶構成", + "scopeLabel": "範圍", + "scopeAll": "所有範圍", + "scopeGlobal": "全域(無標籤)" }, "documentsView": { "loadingDocuments": "正在載入文件…", diff --git a/hindsight-docs/static/bank-template-schema.json b/hindsight-docs/static/bank-template-schema.json index 7f44cc3db6..ecada4a667 100644 --- a/hindsight-docs/static/bank-template-schema.json +++ b/hindsight-docs/static/bank-template-schema.json @@ -585,7 +585,8 @@ "any", "all", "any_strict", - "all_strict" + "all_strict", + "exact" ], "type": "string" }, @@ -715,7 +716,8 @@ "any", "all", "any_strict", - "all_strict" + "all_strict", + "exact" ], "title": "Match", "type": "string" diff --git a/hindsight-docs/static/openapi.json b/hindsight-docs/static/openapi.json index e3e6f6b11c..c2c6062c58 100644 --- a/hindsight-docs/static/openapi.json +++ b/hindsight-docs/static/openapi.json @@ -4127,6 +4127,65 @@ } } }, + "/v1/default/banks/{bank_id}/observations/scopes": { + "get": { + "tags": [ + "Memory" + ], + "summary": "List observation scopes", + "description": "Enumerate the distinct scopes across a bank's observations. Each observation lives under a scope: the exact set of tags it was consolidated with. Returns every distinct scope (tag order normalized) with the number of observations in it; the empty tag list is the global/untagged scope. Use a returned scope with the graph endpoint (tags= & tags_match=exact) to filter observations to exactly that scope.", + "operationId": "list_observation_scopes", + "parameters": [ + { + "name": "bank_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Bank Id" + } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ObservationScopesResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/v1/default/banks/{bank_id}/consolidation/recover": { "post": { "tags": [ @@ -9698,7 +9757,8 @@ "any", "all", "any_strict", - "all_strict" + "all_strict", + "exact" ] }, { @@ -9844,7 +9904,8 @@ "any", "all", "any_strict", - "all_strict" + "all_strict", + "exact" ] }, { @@ -9923,6 +9984,69 @@ "title": "MentalModelTrigger", "description": "Trigger settings for a mental model." }, + "ObservationScope": { + "properties": { + "tags": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Tags", + "description": "The exact tag set defining this scope (normalized order). Empty list is the global/untagged scope." + }, + "count": { + "type": "integer", + "title": "Count", + "description": "Number of observations that live under this scope" + } + }, + "type": "object", + "required": [ + "tags", + "count" + ], + "title": "ObservationScope", + "description": "A distinct observation scope: an exact tag set plus its observation count." + }, + "ObservationScopesResponse": { + "properties": { + "scopes": { + "items": { + "$ref": "#/components/schemas/ObservationScope" + }, + "type": "array", + "title": "Scopes", + "description": "Distinct observation scopes, most populous first" + } + }, + "type": "object", + "required": [ + "scopes" + ], + "title": "ObservationScopesResponse", + "description": "Response model for the observation scopes enumeration endpoint.", + "example": { + "scopes": [ + { + "count": 12, + "tags": [ + "user:alice" + ] + }, + { + "count": 4, + "tags": [ + "user:alice", + "project:apollo" + ] + }, + { + "count": 2, + "tags": [] + } + ] + } + }, "OperationProgress": { "properties": { "stage": { @@ -10385,7 +10509,8 @@ "any", "all", "any_strict", - "all_strict" + "all_strict", + "exact" ], "title": "Tags Match", "description": "How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged), 'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged).", @@ -11042,7 +11167,8 @@ "any", "all", "any_strict", - "all_strict" + "all_strict", + "exact" ], "title": "Tags Match", "description": "How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged), 'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged).", @@ -11622,7 +11748,8 @@ "any", "all", "any_strict", - "all_strict" + "all_strict", + "exact" ], "title": "Match", "default": "any_strict" diff --git a/skills/hindsight-docs/references/openapi.json b/skills/hindsight-docs/references/openapi.json index e3e6f6b11c..c2c6062c58 100644 --- a/skills/hindsight-docs/references/openapi.json +++ b/skills/hindsight-docs/references/openapi.json @@ -4127,6 +4127,65 @@ } } }, + "/v1/default/banks/{bank_id}/observations/scopes": { + "get": { + "tags": [ + "Memory" + ], + "summary": "List observation scopes", + "description": "Enumerate the distinct scopes across a bank's observations. Each observation lives under a scope: the exact set of tags it was consolidated with. Returns every distinct scope (tag order normalized) with the number of observations in it; the empty tag list is the global/untagged scope. Use a returned scope with the graph endpoint (tags= & tags_match=exact) to filter observations to exactly that scope.", + "operationId": "list_observation_scopes", + "parameters": [ + { + "name": "bank_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Bank Id" + } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ObservationScopesResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/v1/default/banks/{bank_id}/consolidation/recover": { "post": { "tags": [ @@ -9698,7 +9757,8 @@ "any", "all", "any_strict", - "all_strict" + "all_strict", + "exact" ] }, { @@ -9844,7 +9904,8 @@ "any", "all", "any_strict", - "all_strict" + "all_strict", + "exact" ] }, { @@ -9923,6 +9984,69 @@ "title": "MentalModelTrigger", "description": "Trigger settings for a mental model." }, + "ObservationScope": { + "properties": { + "tags": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Tags", + "description": "The exact tag set defining this scope (normalized order). Empty list is the global/untagged scope." + }, + "count": { + "type": "integer", + "title": "Count", + "description": "Number of observations that live under this scope" + } + }, + "type": "object", + "required": [ + "tags", + "count" + ], + "title": "ObservationScope", + "description": "A distinct observation scope: an exact tag set plus its observation count." + }, + "ObservationScopesResponse": { + "properties": { + "scopes": { + "items": { + "$ref": "#/components/schemas/ObservationScope" + }, + "type": "array", + "title": "Scopes", + "description": "Distinct observation scopes, most populous first" + } + }, + "type": "object", + "required": [ + "scopes" + ], + "title": "ObservationScopesResponse", + "description": "Response model for the observation scopes enumeration endpoint.", + "example": { + "scopes": [ + { + "count": 12, + "tags": [ + "user:alice" + ] + }, + { + "count": 4, + "tags": [ + "user:alice", + "project:apollo" + ] + }, + { + "count": 2, + "tags": [] + } + ] + } + }, "OperationProgress": { "properties": { "stage": { @@ -10385,7 +10509,8 @@ "any", "all", "any_strict", - "all_strict" + "all_strict", + "exact" ], "title": "Tags Match", "description": "How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged), 'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged).", @@ -11042,7 +11167,8 @@ "any", "all", "any_strict", - "all_strict" + "all_strict", + "exact" ], "title": "Tags Match", "description": "How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged), 'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged).", @@ -11622,7 +11748,8 @@ "any", "all", "any_strict", - "all_strict" + "all_strict", + "exact" ], "title": "Match", "default": "any_strict" From 7033218c1482a9e46a443db3ee72ced43c3e7e85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Thu, 11 Jun 2026 16:43:41 +0200 Subject: [PATCH 2/9] fix(i18n): add missing memoryDetailPanel curation* keys invalidate-memory-dialog.tsx references memoryDetailPanel.curationInvalidateTitle/ Explain/ReasonPlaceholder/Cancel/Invalidate, but these keys were never added to any locale (the parity test passed because all 10 locales lacked them equally), so the invalidate dialog logged IntlError: MISSING_MESSAGE and rendered raw key names. Add all five strings across the 10 locales. Pre-existing gap, unrelated to scopes. --- hindsight-control-plane/src/messages/de.json | 7 ++++++- hindsight-control-plane/src/messages/en.json | 7 ++++++- hindsight-control-plane/src/messages/es.json | 7 ++++++- hindsight-control-plane/src/messages/fr.json | 7 ++++++- hindsight-control-plane/src/messages/ja.json | 7 ++++++- hindsight-control-plane/src/messages/ko.json | 7 ++++++- hindsight-control-plane/src/messages/pt.json | 7 ++++++- hindsight-control-plane/src/messages/yue-Hant.json | 7 ++++++- hindsight-control-plane/src/messages/zh-CN.json | 7 ++++++- hindsight-control-plane/src/messages/zh-TW.json | 7 ++++++- 10 files changed, 60 insertions(+), 10 deletions(-) diff --git a/hindsight-control-plane/src/messages/de.json b/hindsight-control-plane/src/messages/de.json index 8213fc5e65..fc31913009 100644 --- a/hindsight-control-plane/src/messages/de.json +++ b/hindsight-control-plane/src/messages/de.json @@ -1270,7 +1270,12 @@ "editFieldEntities": "Entities", "editEntityPlaceholder": "Add entity…", "editEntityRemove": "Remove {entity}", - "editedBadge": "Edited" + "editedBadge": "Edited", + "curationInvalidateTitle": "Erinnerung ungültig machen", + "curationInvalidateExplain": "Dadurch wird die Erinnerung in das Archiv für ungültige Einträge verschoben. Sie erscheint nicht mehr in Recall oder Reflect, bleibt aber zur Prüfung erhalten und kann später wiederhergestellt werden.", + "curationReasonPlaceholder": "Grund (optional)", + "curationCancel": "Abbrechen", + "curationInvalidate": "Ungültig machen" }, "documentChunkModal": { "documentTitle": "Dokumentdetails", diff --git a/hindsight-control-plane/src/messages/en.json b/hindsight-control-plane/src/messages/en.json index 29a507dbfc..631467b2c2 100644 --- a/hindsight-control-plane/src/messages/en.json +++ b/hindsight-control-plane/src/messages/en.json @@ -1270,7 +1270,12 @@ "editFieldEntities": "Entities", "editEntityPlaceholder": "Add entity…", "editEntityRemove": "Remove {entity}", - "editedBadge": "Edited" + "editedBadge": "Edited", + "curationInvalidateTitle": "Invalidate memory", + "curationInvalidateExplain": "This moves the memory to the invalidated archive. It will no longer surface in recall or reflect, but is kept for audit and can be restored later.", + "curationReasonPlaceholder": "Reason (optional)", + "curationCancel": "Cancel", + "curationInvalidate": "Invalidate" }, "documentChunkModal": { "documentTitle": "Document Details", diff --git a/hindsight-control-plane/src/messages/es.json b/hindsight-control-plane/src/messages/es.json index ce52a2eed3..a223bc7a80 100644 --- a/hindsight-control-plane/src/messages/es.json +++ b/hindsight-control-plane/src/messages/es.json @@ -1270,7 +1270,12 @@ "editFieldEntities": "Entities", "editEntityPlaceholder": "Add entity…", "editEntityRemove": "Remove {entity}", - "editedBadge": "Edited" + "editedBadge": "Edited", + "curationInvalidateTitle": "Invalidar memoria", + "curationInvalidateExplain": "Esto mueve la memoria al archivo de elementos invalidados. Ya no aparecerá en Recall ni Reflect, pero se conserva para auditoría y puede restaurarse más tarde.", + "curationReasonPlaceholder": "Motivo (opcional)", + "curationCancel": "Cancelar", + "curationInvalidate": "Invalidar" }, "documentChunkModal": { "documentTitle": "Detalles del documento", diff --git a/hindsight-control-plane/src/messages/fr.json b/hindsight-control-plane/src/messages/fr.json index bbfd053dfc..1c6ab73a08 100644 --- a/hindsight-control-plane/src/messages/fr.json +++ b/hindsight-control-plane/src/messages/fr.json @@ -1270,7 +1270,12 @@ "editFieldEntities": "Entities", "editEntityPlaceholder": "Add entity…", "editEntityRemove": "Remove {entity}", - "editedBadge": "Edited" + "editedBadge": "Edited", + "curationInvalidateTitle": "Invalider la mémoire", + "curationInvalidateExplain": "Cela déplace la mémoire vers l'archive des éléments invalidés. Elle n'apparaîtra plus dans Recall ni Reflect, mais est conservée à des fins d'audit et peut être restaurée ultérieurement.", + "curationReasonPlaceholder": "Raison (facultatif)", + "curationCancel": "Annuler", + "curationInvalidate": "Invalider" }, "documentChunkModal": { "documentTitle": "Détails du document", diff --git a/hindsight-control-plane/src/messages/ja.json b/hindsight-control-plane/src/messages/ja.json index 2259199dea..5f0fa6b9b7 100644 --- a/hindsight-control-plane/src/messages/ja.json +++ b/hindsight-control-plane/src/messages/ja.json @@ -1270,7 +1270,12 @@ "editFieldEntities": "Entities", "editEntityPlaceholder": "Add entity…", "editEntityRemove": "Remove {entity}", - "editedBadge": "Edited" + "editedBadge": "Edited", + "curationInvalidateTitle": "メモリを無効化", + "curationInvalidateExplain": "メモリを無効化アーカイブに移動します。recall や reflect には表示されなくなりますが、監査用に保持され、後で復元できます。", + "curationReasonPlaceholder": "理由(任意)", + "curationCancel": "キャンセル", + "curationInvalidate": "無効化" }, "documentChunkModal": { "documentTitle": "ドキュメント詳細", diff --git a/hindsight-control-plane/src/messages/ko.json b/hindsight-control-plane/src/messages/ko.json index eff595c42d..e9293d8314 100644 --- a/hindsight-control-plane/src/messages/ko.json +++ b/hindsight-control-plane/src/messages/ko.json @@ -1270,7 +1270,12 @@ "editFieldEntities": "Entities", "editEntityPlaceholder": "Add entity…", "editEntityRemove": "Remove {entity}", - "editedBadge": "Edited" + "editedBadge": "Edited", + "curationInvalidateTitle": "메모리 무효화", + "curationInvalidateExplain": "메모리를 무효화 보관소로 이동합니다. recall이나 reflect에 더 이상 표시되지 않지만 감사를 위해 보관되며 나중에 복원할 수 있습니다.", + "curationReasonPlaceholder": "사유 (선택)", + "curationCancel": "취소", + "curationInvalidate": "무효화" }, "documentChunkModal": { "documentTitle": "문서 상세 정보", diff --git a/hindsight-control-plane/src/messages/pt.json b/hindsight-control-plane/src/messages/pt.json index e45ca98c88..738142f51d 100644 --- a/hindsight-control-plane/src/messages/pt.json +++ b/hindsight-control-plane/src/messages/pt.json @@ -1270,7 +1270,12 @@ "editFieldEntities": "Entities", "editEntityPlaceholder": "Add entity…", "editEntityRemove": "Remove {entity}", - "editedBadge": "Edited" + "editedBadge": "Edited", + "curationInvalidateTitle": "Invalidar memória", + "curationInvalidateExplain": "Isto move a memória para o arquivo de itens invalidados. Ela não aparecerá mais em Recall ou Reflect, mas é mantida para auditoria e pode ser restaurada depois.", + "curationReasonPlaceholder": "Motivo (opcional)", + "curationCancel": "Cancelar", + "curationInvalidate": "Invalidar" }, "documentChunkModal": { "documentTitle": "Detalhes do Documento", diff --git a/hindsight-control-plane/src/messages/yue-Hant.json b/hindsight-control-plane/src/messages/yue-Hant.json index b5d1c3d813..948ae86c1f 100644 --- a/hindsight-control-plane/src/messages/yue-Hant.json +++ b/hindsight-control-plane/src/messages/yue-Hant.json @@ -1270,7 +1270,12 @@ "editFieldEntities": "Entities", "editEntityPlaceholder": "Add entity…", "editEntityRemove": "Remove {entity}", - "editedBadge": "Edited" + "editedBadge": "Edited", + "curationInvalidateTitle": "使記憶失效", + "curationInvalidateExplain": "呢個動作會將記憶移去已失效封存。佢唔會再喺 Recall 或 Reflect 出現,但會保留以供稽核,可以遲啲復原。", + "curationReasonPlaceholder": "原因(可選)", + "curationCancel": "取消", + "curationInvalidate": "使失效" }, "documentChunkModal": { "documentTitle": "文件詳情", diff --git a/hindsight-control-plane/src/messages/zh-CN.json b/hindsight-control-plane/src/messages/zh-CN.json index cfef4ca811..0330ad6b8e 100644 --- a/hindsight-control-plane/src/messages/zh-CN.json +++ b/hindsight-control-plane/src/messages/zh-CN.json @@ -1270,7 +1270,12 @@ "editFieldEntities": "Entities", "editEntityPlaceholder": "Add entity…", "editEntityRemove": "Remove {entity}", - "editedBadge": "Edited" + "editedBadge": "Edited", + "curationInvalidateTitle": "使记忆失效", + "curationInvalidateExplain": "这会将该记忆移至已失效归档。它将不再出现在 Recall 或 Reflect 中,但会保留以供审计,并可在以后恢复。", + "curationReasonPlaceholder": "原因(可选)", + "curationCancel": "取消", + "curationInvalidate": "失效" }, "documentChunkModal": { "documentTitle": "文档详情", diff --git a/hindsight-control-plane/src/messages/zh-TW.json b/hindsight-control-plane/src/messages/zh-TW.json index a5f81dab24..d24c6b5a78 100644 --- a/hindsight-control-plane/src/messages/zh-TW.json +++ b/hindsight-control-plane/src/messages/zh-TW.json @@ -1270,7 +1270,12 @@ "editFieldEntities": "Entities", "editEntityPlaceholder": "Add entity…", "editEntityRemove": "Remove {entity}", - "editedBadge": "Edited" + "editedBadge": "Edited", + "curationInvalidateTitle": "使記憶失效", + "curationInvalidateExplain": "這會將該記憶移至已失效封存。它將不再出現在 Recall 或 Reflect 中,但會保留以供稽核,並可在日後復原。", + "curationReasonPlaceholder": "原因(可選)", + "curationCancel": "取消", + "curationInvalidate": "失效" }, "documentChunkModal": { "documentTitle": "文件詳情", From 58bc9184ab68b91187608d60dab990ad0b786195 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Thu, 11 Jun 2026 17:02:05 +0200 Subject: [PATCH 3/9] fix(observations): keep scope-filter trigger single-line for long/multi tags The scope dropdown trigger relied on SelectValue, which clones the selected item's wrapping pill layout; a multi-tag or long-tag scope (e.g. [session:2, user:nicolo]) wrapped to two lines and overflowed the fixed-height control. Render a compact, single-line, truncating summary in the trigger instead, keeping the full pills only in the open dropdown list. --- .../components/observation-scope-filter.tsx | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/hindsight-control-plane/src/components/observation-scope-filter.tsx b/hindsight-control-plane/src/components/observation-scope-filter.tsx index e4b222373f..8a009017b3 100644 --- a/hindsight-control-plane/src/components/observation-scope-filter.tsx +++ b/hindsight-control-plane/src/components/observation-scope-filter.tsx @@ -2,13 +2,7 @@ import { useTranslations } from "next-intl"; import { Layers } from "lucide-react"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select"; export interface ObservationScope { tags: string[]; @@ -58,6 +52,8 @@ export function ObservationScopeFilter({ scopes, value, onChange }: ObservationS const t = useTranslations("dataView"); const selectValue = value === null ? ALL_VALUE : scopeValue(value); + const selectedCount = + value === null ? null : scopes.find((s) => scopeValue(s.tags) === selectValue)?.count; const handleChange = (next: string) => { if (next === ALL_VALUE) { @@ -67,13 +63,33 @@ export function ObservationScopeFilter({ scopes, value, onChange }: ObservationS onChange(JSON.parse(next) as string[]); }; + // The trigger renders a compact, single-line summary (not the wrapping pills + // used in the list) so a multi-tag or long-tag scope truncates with an + // ellipsis instead of spilling out of the fixed-height control. We render it + // ourselves rather than via SelectValue, which would otherwise clone the + // selected item's wrapping pill layout into the trigger and overflow it. + const renderTriggerLabel = () => { + if (value === null) { + return {t("scopeAll")}; + } + if (value.length === 0) { + return {t("scopeGlobal")}; + } + return {value.map((tag) => `#${tag}`).join(" ")}; + }; + return ( setRecencyBasis(v as RecencyBasis)} - > - - - - - {t("mentioned")} - {t("occurredStart")} - {t("occurredEnd")} - - - + {factType === "observation" && ( +
+
+ +

+ {t("groupByScope")} +

+
+ +
+ )} + {!(factType === "observation" && groupByScope) && ( +
+

+ {t("colorBy")} +

+ +
+ )}

{t("linkTypes")} diff --git a/hindsight-control-plane/src/messages/de.json b/hindsight-control-plane/src/messages/de.json index 591f410445..ca1c8e8eaa 100644 --- a/hindsight-control-plane/src/messages/de.json +++ b/hindsight-control-plane/src/messages/de.json @@ -580,7 +580,8 @@ "memoryComposition": "Speicherzusammensetzung", "scopeLabel": "Bereich", "scopeAll": "Alle Bereiche", - "scopeGlobal": "Global (keine Tags)" + "scopeGlobal": "Global (keine Tags)", + "groupByScope": "Nach Bereich gruppieren" }, "documentsView": { "loadingDocuments": "Dokumente werden geladen...", diff --git a/hindsight-control-plane/src/messages/en.json b/hindsight-control-plane/src/messages/en.json index d934100a32..d3828d55f9 100644 --- a/hindsight-control-plane/src/messages/en.json +++ b/hindsight-control-plane/src/messages/en.json @@ -580,7 +580,8 @@ "memoryComposition": "Memory composition", "scopeLabel": "Scope", "scopeAll": "All scopes", - "scopeGlobal": "Global (no tags)" + "scopeGlobal": "Global (no tags)", + "groupByScope": "Group by scope" }, "documentsView": { "loadingDocuments": "Loading documents...", diff --git a/hindsight-control-plane/src/messages/es.json b/hindsight-control-plane/src/messages/es.json index 38bd103b86..419cdb95f6 100644 --- a/hindsight-control-plane/src/messages/es.json +++ b/hindsight-control-plane/src/messages/es.json @@ -580,7 +580,8 @@ "memoryComposition": "Composición de memorias", "scopeLabel": "Ámbito", "scopeAll": "Todos los ámbitos", - "scopeGlobal": "Global (sin etiquetas)" + "scopeGlobal": "Global (sin etiquetas)", + "groupByScope": "Agrupar por ámbito" }, "documentsView": { "loadingDocuments": "Cargando documentos...", diff --git a/hindsight-control-plane/src/messages/fr.json b/hindsight-control-plane/src/messages/fr.json index 1d974df32b..a750971311 100644 --- a/hindsight-control-plane/src/messages/fr.json +++ b/hindsight-control-plane/src/messages/fr.json @@ -580,7 +580,8 @@ "memoryComposition": "Composition de la mémoire", "scopeLabel": "Portée", "scopeAll": "Toutes les portées", - "scopeGlobal": "Global (sans tags)" + "scopeGlobal": "Global (sans tags)", + "groupByScope": "Grouper par portée" }, "documentsView": { "loadingDocuments": "Chargement des documents...", diff --git a/hindsight-control-plane/src/messages/ja.json b/hindsight-control-plane/src/messages/ja.json index db960b0693..ee12a439f4 100644 --- a/hindsight-control-plane/src/messages/ja.json +++ b/hindsight-control-plane/src/messages/ja.json @@ -580,7 +580,8 @@ "memoryComposition": "メモリ構成", "scopeLabel": "スコープ", "scopeAll": "すべてのスコープ", - "scopeGlobal": "グローバル(タグなし)" + "scopeGlobal": "グローバル(タグなし)", + "groupByScope": "スコープでグループ化" }, "documentsView": { "loadingDocuments": "ドキュメントを読み込み中...", diff --git a/hindsight-control-plane/src/messages/ko.json b/hindsight-control-plane/src/messages/ko.json index a7da02a4ee..4475b9b196 100644 --- a/hindsight-control-plane/src/messages/ko.json +++ b/hindsight-control-plane/src/messages/ko.json @@ -580,7 +580,8 @@ "memoryComposition": "메모리 구성", "scopeLabel": "범위", "scopeAll": "모든 범위", - "scopeGlobal": "전역(태그 없음)" + "scopeGlobal": "전역(태그 없음)", + "groupByScope": "범위별 그룹화" }, "documentsView": { "loadingDocuments": "문서 불러오는 중...", diff --git a/hindsight-control-plane/src/messages/pt.json b/hindsight-control-plane/src/messages/pt.json index 56493406d6..5e1851a064 100644 --- a/hindsight-control-plane/src/messages/pt.json +++ b/hindsight-control-plane/src/messages/pt.json @@ -580,7 +580,8 @@ "memoryComposition": "Composição da memória", "scopeLabel": "Escopo", "scopeAll": "Todos os escopos", - "scopeGlobal": "Global (sem tags)" + "scopeGlobal": "Global (sem tags)", + "groupByScope": "Agrupar por escopo" }, "documentsView": { "loadingDocuments": "Carregando documentos...", diff --git a/hindsight-control-plane/src/messages/yue-Hant.json b/hindsight-control-plane/src/messages/yue-Hant.json index bbff765df3..43a908ff67 100644 --- a/hindsight-control-plane/src/messages/yue-Hant.json +++ b/hindsight-control-plane/src/messages/yue-Hant.json @@ -580,7 +580,8 @@ "memoryComposition": "記憶構成", "scopeLabel": "範圍", "scopeAll": "所有範圍", - "scopeGlobal": "全域(冇標籤)" + "scopeGlobal": "全域(冇標籤)", + "groupByScope": "按範圍分組" }, "documentsView": { "loadingDocuments": "載入文件中...", diff --git a/hindsight-control-plane/src/messages/zh-CN.json b/hindsight-control-plane/src/messages/zh-CN.json index 1fb95d640f..57ea764200 100644 --- a/hindsight-control-plane/src/messages/zh-CN.json +++ b/hindsight-control-plane/src/messages/zh-CN.json @@ -580,7 +580,8 @@ "memoryComposition": "记忆构成", "scopeLabel": "范围", "scopeAll": "所有范围", - "scopeGlobal": "全局(无标签)" + "scopeGlobal": "全局(无标签)", + "groupByScope": "按范围分组" }, "documentsView": { "loadingDocuments": "正在加载文档...", diff --git a/hindsight-control-plane/src/messages/zh-TW.json b/hindsight-control-plane/src/messages/zh-TW.json index 71e3c44f72..3009c327e8 100644 --- a/hindsight-control-plane/src/messages/zh-TW.json +++ b/hindsight-control-plane/src/messages/zh-TW.json @@ -580,7 +580,8 @@ "memoryComposition": "記憶構成", "scopeLabel": "範圍", "scopeAll": "所有範圍", - "scopeGlobal": "全域(無標籤)" + "scopeGlobal": "全域(無標籤)", + "groupByScope": "按範圍分組" }, "documentsView": { "loadingDocuments": "正在載入文件…", From 7c54d87624885ce4584333d9ec946a3145e49ed8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Thu, 11 Jun 2026 17:59:50 +0200 Subject: [PATCH 6/9] fix(observations): cap scope dropdown height to the viewport With many scopes the scope filter dropdown grew past the bottom of the screen. Cap its height at min(60vh, --radix-select-content-available-height) so it fits the space below the trigger and scrolls for the rest, instead of overflowing. --- .../src/components/observation-scope-filter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hindsight-control-plane/src/components/observation-scope-filter.tsx b/hindsight-control-plane/src/components/observation-scope-filter.tsx index 8a009017b3..c405e4f915 100644 --- a/hindsight-control-plane/src/components/observation-scope-filter.tsx +++ b/hindsight-control-plane/src/components/observation-scope-filter.tsx @@ -89,7 +89,7 @@ export function ObservationScopeFilter({ scopes, value, onChange }: ObservationS )}

- + {t("scopeAll")} {scopes.map((scope) => ( From d62e10ad2f960ffd9e9abfbe2e3a9b6dcbbb082f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Thu, 11 Jun 2026 18:05:44 +0200 Subject: [PATCH 7/9] feat(observations): make the scope filter a searchable combobox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the plain Select with a Popover + Command (cmdk) combobox so scopes can be searched by typing — matching the tag filter's search UX — which matters once a bank has many scopes. Uses a substring filter over each scope's tags (not cmdk's fuzzy default, which over-matches scattered letters). Keeps the compact, single-line, height-capped trigger; selection still applies exact-scope filtering. --- .../src/components/data-view.tsx | 1 - .../components/observation-scope-filter.tsx | 145 ++++++++++++------ hindsight-control-plane/src/messages/de.json | 4 +- hindsight-control-plane/src/messages/en.json | 4 +- hindsight-control-plane/src/messages/es.json | 4 +- hindsight-control-plane/src/messages/fr.json | 4 +- hindsight-control-plane/src/messages/ja.json | 4 +- hindsight-control-plane/src/messages/ko.json | 4 +- hindsight-control-plane/src/messages/pt.json | 4 +- .../src/messages/yue-Hant.json | 4 +- .../src/messages/zh-CN.json | 4 +- .../src/messages/zh-TW.json | 4 +- 12 files changed, 131 insertions(+), 55 deletions(-) diff --git a/hindsight-control-plane/src/components/data-view.tsx b/hindsight-control-plane/src/components/data-view.tsx index e033a8fa6d..496d460e82 100644 --- a/hindsight-control-plane/src/components/data-view.tsx +++ b/hindsight-control-plane/src/components/data-view.tsx @@ -481,7 +481,6 @@ export function DataView({ loadScopes(); }, 4000); return () => clearInterval(id); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [isConsolidating, currentBank]); // Enforce 50 node limit to prevent UI instability, default to 20 or max whichever is smaller diff --git a/hindsight-control-plane/src/components/observation-scope-filter.tsx b/hindsight-control-plane/src/components/observation-scope-filter.tsx index c405e4f915..cc39fe071b 100644 --- a/hindsight-control-plane/src/components/observation-scope-filter.tsx +++ b/hindsight-control-plane/src/components/observation-scope-filter.tsx @@ -1,8 +1,19 @@ "use client"; +import { useState } from "react"; import { useTranslations } from "next-intl"; -import { Layers } from "lucide-react"; -import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select"; +import { Layers, Check, ChevronsUpDown } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; export interface ObservationScope { tags: string[]; @@ -16,10 +27,8 @@ interface ObservationScopeFilterProps { onChange: (scope: string[] | null) => void; } -const ALL_VALUE = "__all__"; - -/** Encode a scope's tag set into a stable Select value (JSON of the tags). */ -function scopeValue(tags: string[]): string { +/** Stable key for a scope's tag set (order-independent, matches the trigger value). */ +function scopeKey(tags: string[]): string { return JSON.stringify(tags); } @@ -44,31 +53,28 @@ function ScopeTags({ tags, globalLabel }: { tags: string[]; globalLabel: string } /** - * Dropdown that enumerates every distinct observation scope (exact tag set) in - * the bank and lets the user filter observations down to one scope. The empty - * tag set is the global/untagged scope; "All scopes" clears the filter. + * Searchable single-select for observation scopes (exact tag sets). Mirrors the + * tag filter's type-to-search UX via a Popover + Command combobox: the trigger + * shows a compact, single-line summary of the selected scope; the dropdown lists + * every distinct scope with counts and filters as you type. The empty tag set is + * the global/untagged scope; "All scopes" clears the filter. */ export function ObservationScopeFilter({ scopes, value, onChange }: ObservationScopeFilterProps) { const t = useTranslations("dataView"); + const [open, setOpen] = useState(false); - const selectValue = value === null ? ALL_VALUE : scopeValue(value); + const selectedKey = value === null ? null : scopeKey(value); const selectedCount = - value === null ? null : scopes.find((s) => scopeValue(s.tags) === selectValue)?.count; + value === null ? null : scopes.find((s) => scopeKey(s.tags) === selectedKey)?.count; - const handleChange = (next: string) => { - if (next === ALL_VALUE) { - onChange(null); - return; - } - onChange(JSON.parse(next) as string[]); + const select = (scope: string[] | null) => { + onChange(scope); + setOpen(false); }; - // The trigger renders a compact, single-line summary (not the wrapping pills - // used in the list) so a multi-tag or long-tag scope truncates with an - // ellipsis instead of spilling out of the fixed-height control. We render it - // ourselves rather than via SelectValue, which would otherwise clone the - // selected item's wrapping pill layout into the trigger and overflow it. - const renderTriggerLabel = () => { + // Compact, single-line trigger summary (the full pills live in the dropdown), + // so a multi-tag or long-tag scope truncates instead of overflowing. + const triggerLabel = () => { if (value === null) { return {t("scopeAll")}; } @@ -79,27 +85,78 @@ export function ObservationScopeFilter({ scopes, value, onChange }: ObservationS }; return ( - + + {triggerLabel()} + {selectedCount != null && ( + ({selectedCount}) + )} + + + + + + { + // Substring match over the scope's tags (like the tag filter), not + // cmdk's default fuzzy scoring which over-matches scattered letters + // across long multi-tag scopes. + const q = search.trim().toLowerCase(); + if (!q) return 1; + const haystack = `${value} ${(keywords ?? []).join(" ")}`.toLowerCase(); + return haystack.includes(q) ? 1 : 0; + }} + > + + + {t("scopeNoResults")} + + select(null)} + > + + {t("scopeAll")} + + {scopes.map((scope) => { + const key = scopeKey(scope.tags); + const isSelected = selectedKey === key; + return ( + select(scope.tags)} + > + + + + + ({scope.count}) + + ); + })} + + + + + ); } diff --git a/hindsight-control-plane/src/messages/de.json b/hindsight-control-plane/src/messages/de.json index ca1c8e8eaa..5c2070b2f2 100644 --- a/hindsight-control-plane/src/messages/de.json +++ b/hindsight-control-plane/src/messages/de.json @@ -581,7 +581,9 @@ "scopeLabel": "Bereich", "scopeAll": "Alle Bereiche", "scopeGlobal": "Global (keine Tags)", - "groupByScope": "Nach Bereich gruppieren" + "groupByScope": "Nach Bereich gruppieren", + "scopeSearch": "Bereiche suchen…", + "scopeNoResults": "Keine Bereiche gefunden" }, "documentsView": { "loadingDocuments": "Dokumente werden geladen...", diff --git a/hindsight-control-plane/src/messages/en.json b/hindsight-control-plane/src/messages/en.json index d3828d55f9..2f22c0fda1 100644 --- a/hindsight-control-plane/src/messages/en.json +++ b/hindsight-control-plane/src/messages/en.json @@ -581,7 +581,9 @@ "scopeLabel": "Scope", "scopeAll": "All scopes", "scopeGlobal": "Global (no tags)", - "groupByScope": "Group by scope" + "groupByScope": "Group by scope", + "scopeSearch": "Search scopes…", + "scopeNoResults": "No scopes found" }, "documentsView": { "loadingDocuments": "Loading documents...", diff --git a/hindsight-control-plane/src/messages/es.json b/hindsight-control-plane/src/messages/es.json index 419cdb95f6..3c354d6966 100644 --- a/hindsight-control-plane/src/messages/es.json +++ b/hindsight-control-plane/src/messages/es.json @@ -581,7 +581,9 @@ "scopeLabel": "Ámbito", "scopeAll": "Todos los ámbitos", "scopeGlobal": "Global (sin etiquetas)", - "groupByScope": "Agrupar por ámbito" + "groupByScope": "Agrupar por ámbito", + "scopeSearch": "Buscar ámbitos…", + "scopeNoResults": "No se encontraron ámbitos" }, "documentsView": { "loadingDocuments": "Cargando documentos...", diff --git a/hindsight-control-plane/src/messages/fr.json b/hindsight-control-plane/src/messages/fr.json index a750971311..9155844b07 100644 --- a/hindsight-control-plane/src/messages/fr.json +++ b/hindsight-control-plane/src/messages/fr.json @@ -581,7 +581,9 @@ "scopeLabel": "Portée", "scopeAll": "Toutes les portées", "scopeGlobal": "Global (sans tags)", - "groupByScope": "Grouper par portée" + "groupByScope": "Grouper par portée", + "scopeSearch": "Rechercher des portées…", + "scopeNoResults": "Aucune portée trouvée" }, "documentsView": { "loadingDocuments": "Chargement des documents...", diff --git a/hindsight-control-plane/src/messages/ja.json b/hindsight-control-plane/src/messages/ja.json index ee12a439f4..f7d249c621 100644 --- a/hindsight-control-plane/src/messages/ja.json +++ b/hindsight-control-plane/src/messages/ja.json @@ -581,7 +581,9 @@ "scopeLabel": "スコープ", "scopeAll": "すべてのスコープ", "scopeGlobal": "グローバル(タグなし)", - "groupByScope": "スコープでグループ化" + "groupByScope": "スコープでグループ化", + "scopeSearch": "スコープを検索…", + "scopeNoResults": "スコープが見つかりません" }, "documentsView": { "loadingDocuments": "ドキュメントを読み込み中...", diff --git a/hindsight-control-plane/src/messages/ko.json b/hindsight-control-plane/src/messages/ko.json index 4475b9b196..72c6588485 100644 --- a/hindsight-control-plane/src/messages/ko.json +++ b/hindsight-control-plane/src/messages/ko.json @@ -581,7 +581,9 @@ "scopeLabel": "범위", "scopeAll": "모든 범위", "scopeGlobal": "전역(태그 없음)", - "groupByScope": "범위별 그룹화" + "groupByScope": "범위별 그룹화", + "scopeSearch": "범위 검색…", + "scopeNoResults": "범위를 찾을 수 없음" }, "documentsView": { "loadingDocuments": "문서 불러오는 중...", diff --git a/hindsight-control-plane/src/messages/pt.json b/hindsight-control-plane/src/messages/pt.json index 5e1851a064..b5ceacccb0 100644 --- a/hindsight-control-plane/src/messages/pt.json +++ b/hindsight-control-plane/src/messages/pt.json @@ -581,7 +581,9 @@ "scopeLabel": "Escopo", "scopeAll": "Todos os escopos", "scopeGlobal": "Global (sem tags)", - "groupByScope": "Agrupar por escopo" + "groupByScope": "Agrupar por escopo", + "scopeSearch": "Pesquisar escopos…", + "scopeNoResults": "Nenhum escopo encontrado" }, "documentsView": { "loadingDocuments": "Carregando documentos...", diff --git a/hindsight-control-plane/src/messages/yue-Hant.json b/hindsight-control-plane/src/messages/yue-Hant.json index 43a908ff67..768061422f 100644 --- a/hindsight-control-plane/src/messages/yue-Hant.json +++ b/hindsight-control-plane/src/messages/yue-Hant.json @@ -581,7 +581,9 @@ "scopeLabel": "範圍", "scopeAll": "所有範圍", "scopeGlobal": "全域(冇標籤)", - "groupByScope": "按範圍分組" + "groupByScope": "按範圍分組", + "scopeSearch": "搜尋範圍…", + "scopeNoResults": "搵唔到範圍" }, "documentsView": { "loadingDocuments": "載入文件中...", diff --git a/hindsight-control-plane/src/messages/zh-CN.json b/hindsight-control-plane/src/messages/zh-CN.json index 57ea764200..706c153291 100644 --- a/hindsight-control-plane/src/messages/zh-CN.json +++ b/hindsight-control-plane/src/messages/zh-CN.json @@ -581,7 +581,9 @@ "scopeLabel": "范围", "scopeAll": "所有范围", "scopeGlobal": "全局(无标签)", - "groupByScope": "按范围分组" + "groupByScope": "按范围分组", + "scopeSearch": "搜索范围…", + "scopeNoResults": "未找到范围" }, "documentsView": { "loadingDocuments": "正在加载文档...", diff --git a/hindsight-control-plane/src/messages/zh-TW.json b/hindsight-control-plane/src/messages/zh-TW.json index 3009c327e8..25fc89fd46 100644 --- a/hindsight-control-plane/src/messages/zh-TW.json +++ b/hindsight-control-plane/src/messages/zh-TW.json @@ -581,7 +581,9 @@ "scopeLabel": "範圍", "scopeAll": "所有範圍", "scopeGlobal": "全域(無標籤)", - "groupByScope": "按範圍分組" + "groupByScope": "按範圍分組", + "scopeSearch": "搜尋範圍…", + "scopeNoResults": "找不到範圍" }, "documentsView": { "loadingDocuments": "正在載入文件…", From a8d9e77087e68dc786f11cf1b90575b9ee12edde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Thu, 11 Jun 2026 18:41:46 +0200 Subject: [PATCH 8/9] chore(cli): mark list_observation_scopes UI-only in coverage manifest The new scope-enumeration endpoint powers the control-plane scope filter/clusters and isn't a useful end-user CLI command, so add it to the [skip] list (matches the other UI-only endpoints) to satisfy check-cli-coverage. --- hindsight-cli/.openapi-coverage.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/hindsight-cli/.openapi-coverage.toml b/hindsight-cli/.openapi-coverage.toml index 3fdfc2bf80..35e91b7a2c 100644 --- a/hindsight-cli/.openapi-coverage.toml +++ b/hindsight-cli/.openapi-coverage.toml @@ -36,6 +36,10 @@ get_entity_graph = "UI-only endpoint for the control plane entity constellation" # Document chunks listing is a UI-only endpoint for the document detail dialog. list_document_chunks = "UI-only endpoint for the control plane document detail dialog" +# Observation scope enumeration powers the control-plane scope filter/clusters; +# not a useful end-user CLI command. +list_observation_scopes = "UI-only endpoint for the control plane observation scope filter" + # Reprocess triggers an async retain re-run; exposed in the control plane UI only. reprocess_document = "UI-only endpoint for the control plane document detail dialog" From 78338637f21c2ebbebff546fa4c8f9962cc4bdc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Thu, 11 Jun 2026 18:41:46 +0200 Subject: [PATCH 9/9] fix(tests): import TokenUsage from response_models #2135 removed the TokenUsage re-export from llm_wrapper, but test_load_large_batch and test_retain still imported it from there, breaking test collection across the API test jobs. Import it from response_models (where it's defined), matching every other test. --- hindsight-api-slim/tests/test_load_large_batch.py | 2 +- hindsight-api-slim/tests/test_retain.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/hindsight-api-slim/tests/test_load_large_batch.py b/hindsight-api-slim/tests/test_load_large_batch.py index a97423d5d6..26d0956278 100644 --- a/hindsight-api-slim/tests/test_load_large_batch.py +++ b/hindsight-api-slim/tests/test_load_large_batch.py @@ -21,7 +21,7 @@ from hindsight_api.engine.query_analyzer import DateparserQueryAnalyzer from hindsight_api.engine.task_backend import SyncTaskBackend from hindsight_api.engine.retain.fact_extraction import FactExtractionResponse, ExtractedFact -from hindsight_api.engine.llm_wrapper import TokenUsage +from hindsight_api.engine.response_models import TokenUsage logger = logging.getLogger(__name__) diff --git a/hindsight-api-slim/tests/test_retain.py b/hindsight-api-slim/tests/test_retain.py index 64e23d828f..4eb58ec0d9 100644 --- a/hindsight-api-slim/tests/test_retain.py +++ b/hindsight-api-slim/tests/test_retain.py @@ -3205,7 +3205,7 @@ async def test_temporal_links_scoped_by_fact_type(memory, request_context): import pytest_asyncio -from hindsight_api.engine.llm_wrapper import TokenUsage +from hindsight_api.engine.response_models import TokenUsage from hindsight_api.engine.memory_engine import MemoryEngine from hindsight_api.engine.task_backend import SyncTaskBackend