diff --git a/hindsight-api-slim/hindsight_api/api/http.py b/hindsight-api-slim/hindsight_api/api/http.py index b698409df6..a177756d88 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.""" @@ -1405,6 +1432,12 @@ class DocumentResponse(BaseModel): tags: list[str] = FieldWithDefault(list, description="Tags associated with this document") document_metadata: dict[str, Any] | None = Field(default=None, description="Document metadata") retain_params: dict[str, Any] | None = Field(default=None, description="Parameters used during retain") + observation_scopes: str | list[list[str]] | None = Field( + default=None, + description="The observation_scopes spec configured at retain time (e.g. 'all_combinations', " + "'per_tag', or explicit tag-set lists), captured into retain_params. None when none was set " + "(default 'combined' scoping) or for documents retained before this was captured.", + ) class UpdateDocumentRequest(BaseModel): @@ -5771,6 +5804,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..f06cacffab 100644 --- a/hindsight-api-slim/hindsight_api/engine/memory_engine.py +++ b/hindsight-api-slim/hindsight_api/engine/memory_engine.py @@ -5153,6 +5153,12 @@ async def get_document( # document_metadata is sourced from retain_params.metadata document_metadata = retain_params_parsed.get("metadata") if retain_params_parsed else None + # observation_scopes is captured into retain_params at retain time + # (see _build_retain_params); surface it as a top-level field so the + # UI can show which scoping was requested. Only present for documents + # retained after this was added. + observation_scopes = retain_params_parsed.get("observation_scopes") if retain_params_parsed else None + return { "id": doc["id"], "bank_id": doc["bank_id"], @@ -5169,6 +5175,7 @@ async def get_document( "tags": list(doc["tags"]) if doc["tags"] else [], "document_metadata": document_metadata or None, "retain_params": retain_params_parsed or None, + "observation_scopes": observation_scopes or None, } async def delete_document( @@ -5718,6 +5725,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 +6344,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/retain/orchestrator.py b/hindsight-api-slim/hindsight_api/engine/retain/orchestrator.py index 4b9f873e40..c1af078167 100644 --- a/hindsight-api-slim/hindsight_api/engine/retain/orchestrator.py +++ b/hindsight-api-slim/hindsight_api/engine/retain/orchestrator.py @@ -273,6 +273,8 @@ def _build_retain_params(contents_dicts, document_tags=None, doc_contents=None): ) if first_item.get("metadata"): retain_params["metadata"] = first_item["metadata"] + if first_item.get("observation_scopes") is not None: + retain_params["observation_scopes"] = first_item["observation_scopes"] return retain_params, merged_tags 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_document_tracking.py b/hindsight-api-slim/tests/test_document_tracking.py index b3d17cd7ed..db89b1be9e 100644 --- a/hindsight-api-slim/tests/test_document_tracking.py +++ b/hindsight-api-slim/tests/test_document_tracking.py @@ -206,6 +206,58 @@ async def test_document_without_metadata(memory, request_context): await memory.delete_bank(bank_id, request_context=request_context) +@pytest.mark.asyncio +async def test_document_observation_scopes_from_retain_params(memory, request_context): + """observation_scopes passed at retain time is captured into retain_params and surfaced by get_document.""" + bank_id = f"test_doc_obs_scopes_{datetime.now(timezone.utc).timestamp()}" + + try: + document_id = "doc-with-scopes" + await memory.retain_batch_async( + bank_id=bank_id, + contents=[ + { + "content": "Alice and Bob are friends.", + "tags": ["alice", "bob"], + "observation_scopes": "all_combinations", + } + ], + document_id=document_id, + request_context=request_context, + ) + + doc = await memory.get_document(document_id, bank_id, request_context=request_context) + assert doc is not None + # Surfaced as a top-level field and persisted in retain_params. + assert doc["observation_scopes"] == "all_combinations" + assert doc["retain_params"]["observation_scopes"] == "all_combinations" + + finally: + await memory.delete_bank(bank_id, request_context=request_context) + + +@pytest.mark.asyncio +async def test_document_observation_scopes_none_when_unset(memory, request_context): + """get_document returns observation_scopes None when none was configured at retain time.""" + bank_id = f"test_doc_no_scopes_{datetime.now(timezone.utc).timestamp()}" + + try: + document_id = "doc-no-scopes" + await memory.retain_async( + bank_id=bank_id, + content="Bob works at Microsoft.", + document_id=document_id, + request_context=request_context, + ) + + doc = await memory.get_document(document_id, bank_id, request_context=request_context) + assert doc is not None + assert doc["observation_scopes"] is None + + finally: + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio @pytest.mark.hs_llm_core async def test_document_persisted_with_zero_facts(memory_real_llm, request_context): 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_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 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-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" diff --git a/hindsight-clients/go/api/openapi.yaml b/hindsight-clients/go/api/openapi.yaml index 87068636f1..f1a8b18d86 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 @@ -5309,6 +5351,8 @@ components: retain_params: additionalProperties: {} nullable: true + observation_scopes: + $ref: '#/components/schemas/Observation_Scopes' required: - bank_id - content_hash @@ -6519,6 +6563,7 @@ components: - all - any_strict - all_strict + - exact nullable: true type: string tag_groups: @@ -6607,6 +6652,7 @@ components: - all - any_strict - all_strict + - exact nullable: true type: string tag_groups: @@ -6624,6 +6670,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 +6945,7 @@ components: - all - any_strict - all_strict + - exact title: Tags Match type: string tag_groups: @@ -7181,6 +7268,7 @@ components: - all - any_strict - all_strict + - exact title: Tags Match type: string tag_groups: @@ -7482,6 +7570,7 @@ components: - all - any_strict - all_strict + - exact title: Match type: string required: @@ -8040,6 +8129,20 @@ components: - id - url title: WebhookResponse + Observation_Scopes: + anyOf: + - type: string + - items: + items: + type: string + type: array + type: array + description: "The observation_scopes spec configured at retain time (e.g. 'all_combinations',\ + \ 'per_tag', or explicit tag-set lists), captured into retain_params. None\ + \ when none was set (default 'combined' scoping) or for documents retained\ + \ before this was captured." + nullable: true + title: Observation Scopes Timestamp: anyOf: - format: date-time 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_document_response.go b/hindsight-clients/go/model_document_response.go index b6261f1fbf..bebd080f91 100644 --- a/hindsight-clients/go/model_document_response.go +++ b/hindsight-clients/go/model_document_response.go @@ -33,6 +33,7 @@ type DocumentResponse struct { Tags []string `json:"tags,omitempty"` DocumentMetadata map[string]interface{} `json:"document_metadata,omitempty"` RetainParams map[string]interface{} `json:"retain_params,omitempty"` + ObservationScopes NullableObservationScopes `json:"observation_scopes,omitempty"` } type _DocumentResponse DocumentResponse @@ -364,6 +365,48 @@ func (o *DocumentResponse) SetRetainParams(v map[string]interface{}) { o.RetainParams = v } +// GetObservationScopes returns the ObservationScopes field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *DocumentResponse) GetObservationScopes() ObservationScopes { + if o == nil || IsNil(o.ObservationScopes.Get()) { + var ret ObservationScopes + return ret + } + return *o.ObservationScopes.Get() +} + +// GetObservationScopesOk returns a tuple with the ObservationScopes field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *DocumentResponse) GetObservationScopesOk() (*ObservationScopes, bool) { + if o == nil { + return nil, false + } + return o.ObservationScopes.Get(), o.ObservationScopes.IsSet() +} + +// HasObservationScopes returns a boolean if a field has been set. +func (o *DocumentResponse) HasObservationScopes() bool { + if o != nil && o.ObservationScopes.IsSet() { + return true + } + + return false +} + +// SetObservationScopes gets a reference to the given NullableObservationScopes and assigns it to the ObservationScopes field. +func (o *DocumentResponse) SetObservationScopes(v ObservationScopes) { + o.ObservationScopes.Set(&v) +} +// SetObservationScopesNil sets the value for ObservationScopes to be an explicit nil +func (o *DocumentResponse) SetObservationScopesNil() { + o.ObservationScopes.Set(nil) +} + +// UnsetObservationScopes ensures that no value is present for ObservationScopes, not even an explicit nil +func (o *DocumentResponse) UnsetObservationScopes() { + o.ObservationScopes.Unset() +} + func (o DocumentResponse) MarshalJSON() ([]byte, error) { toSerialize,err := o.ToMap() if err != nil { @@ -393,6 +436,9 @@ func (o DocumentResponse) ToMap() (map[string]interface{}, error) { if o.RetainParams != nil { toSerialize["retain_params"] = o.RetainParams } + if o.ObservationScopes.IsSet() { + toSerialize["observation_scopes"] = o.ObservationScopes.Get() + } return toSerialize, nil } 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/document_response.py b/hindsight-clients/python/hindsight_client_api/models/document_response.py index 06e6817e7d..595b4b6de0 100644 --- a/hindsight-clients/python/hindsight_client_api/models/document_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/document_response.py @@ -19,6 +19,7 @@ from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr from typing import Any, ClassVar, Dict, List, Optional +from hindsight_client_api.models.observation_scopes import ObservationScopes from typing import Optional, Set from typing_extensions import Self @@ -37,7 +38,8 @@ class DocumentResponse(BaseModel): tags: Optional[List[StrictStr]] = Field(default=None, description="Tags associated with this document") document_metadata: Optional[Dict[str, Any]] = None retain_params: Optional[Dict[str, Any]] = None - __properties: ClassVar[List[str]] = ["id", "bank_id", "original_text", "content_hash", "created_at", "updated_at", "memory_unit_count", "nodes_by_fact_type", "tags", "document_metadata", "retain_params"] + observation_scopes: Optional[ObservationScopes] = None + __properties: ClassVar[List[str]] = ["id", "bank_id", "original_text", "content_hash", "created_at", "updated_at", "memory_unit_count", "nodes_by_fact_type", "tags", "document_metadata", "retain_params", "observation_scopes"] model_config = ConfigDict( populate_by_name=True, @@ -78,6 +80,9 @@ def to_dict(self) -> Dict[str, Any]: exclude=excluded_fields, exclude_none=True, ) + # override the default output from pydantic by calling `to_dict()` of observation_scopes + if self.observation_scopes: + _dict['observation_scopes'] = self.observation_scopes.to_dict() # set to None if original_text (nullable) is None # and model_fields_set contains the field if self.original_text is None and "original_text" in self.model_fields_set: @@ -103,6 +108,11 @@ def to_dict(self) -> Dict[str, Any]: if self.retain_params is None and "retain_params" in self.model_fields_set: _dict['retain_params'] = None + # set to None if observation_scopes (nullable) is None + # and model_fields_set contains the field + if self.observation_scopes is None and "observation_scopes" in self.model_fields_set: + _dict['observation_scopes'] = None + return _dict @classmethod @@ -125,7 +135,8 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "nodes_by_fact_type": obj.get("nodes_by_fact_type"), "tags": obj.get("tags"), "document_metadata": obj.get("document_metadata"), - "retain_params": obj.get("retain_params") + "retain_params": obj.get("retain_params"), + "observation_scopes": ObservationScopes.from_dict(obj["observation_scopes"]) if obj.get("observation_scopes") is not None else None }) return _obj 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..4228d0674a 100644 --- a/hindsight-clients/typescript/generated/types.gen.ts +++ b/hindsight-clients/typescript/generated/types.gen.ts @@ -1441,6 +1441,12 @@ export type DocumentResponse = { retain_params?: { [key: string]: unknown; } | null; + /** + * Observation Scopes + * + * The observation_scopes spec configured at retain time (e.g. 'all_combinations', 'per_tag', or explicit tag-set lists), captured into retain_params. None when none was set (default 'combined' scoping) or for documents retained before this was captured. + */ + observation_scopes?: string | Array> | null; }; /** @@ -2384,7 +2390,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 +2458,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 +2487,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 +2773,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 +3094,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 +3382,7 @@ export type TagGroupLeaf = { /** * Match */ - match?: "any" | "all" | "any_strict" | "all_strict"; + match?: "any" | "all" | "any_strict" | "all_strict" | "exact"; }; /** @@ -6149,6 +6189,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/constellation.tsx b/hindsight-control-plane/src/components/constellation.tsx index 59362a5f51..f75cae2951 100644 --- a/hindsight-control-plane/src/components/constellation.tsx +++ b/hindsight-control-plane/src/components/constellation.tsx @@ -66,6 +66,18 @@ export interface ConstellationProps { * "co-occurrences") so the reader knows what the node size represents. */ sizeLegendLabel?: string; + /** + * Optional clustering. When provided, nodes sharing a key are laid out around a + * common centroid (instead of the default id-hash ring) and wrapped in a + * translucent "blob", so each group reads as a distinct visual cluster. Used to + * group observations by scope (exact tag set). Return null to leave a node + * unclustered (it falls back to the ring layout). + */ + clusterKeyFn?: (node: GraphNode) => string | null; + /** Fill/outline color for a cluster key (also tints the cluster's node dots). */ + clusterColorFn?: (key: string) => string; + /** Short human label for a cluster key, drawn near the blob. */ + clusterLabelFn?: (key: string) => string; } // ============================================================================ @@ -76,6 +88,41 @@ function lerp(a: number, b: number, t: number): number { return a + (b - a) * t; } +function hexToRgba(hex: string, alpha: number): string { + // Handle non-hex formats + if (!hex.startsWith("#")) return hex; + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `rgba(${r},${g},${b},${alpha})`; +} + +// Monotone-chain convex hull. Returns the hull vertices (CCW) for a set of 2D +// points; used to wrap a cluster's nodes in a translucent blob. Fewer than 3 +// points have no polygon — the caller draws a circle instead. +function convexHull(points: Array<[number, number]>): Array<[number, number]> { + if (points.length < 3) return points.slice(); + const pts = points.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]); + const cross = (o: number[], a: number[], b: number[]) => + (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); + const lower: Array<[number, number]> = []; + for (const p of pts) { + while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) + lower.pop(); + lower.push(p); + } + const upper: Array<[number, number]> = []; + for (let i = pts.length - 1; i >= 0; i--) { + const p = pts[i]; + while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) + upper.pop(); + upper.push(p); + } + lower.pop(); + upper.pop(); + return lower.concat(upper); +} + /** * Map a 0..1 value to a perceptually monotonic cool→warm ramp. * 0 = cool blue (low / older / few links), 1 = warm orange-red (high / newer / many). @@ -190,6 +237,9 @@ export function Constellation({ heatLegendEndpoints, compactLabels, sizeLegendLabel, + clusterKeyFn, + clusterColorFn, + clusterLabelFn, }: ConstellationProps) { const t = useTranslations("constellation"); const wrapperRef = useRef(null); @@ -222,9 +272,14 @@ export function Constellation({ }); // ----- Prepare data with Pretext ----- - const { preparedNodes, linksByNode, linksWithIndices } = useMemo(() => { + const { preparedNodes, linksByNode, linksWithIndices, clusters } = useMemo(() => { if (!data.nodes.length) - return { preparedNodes: [], linksByNode: new Map(), linksWithIndices: [] }; + return { + preparedNodes: [], + linksByNode: new Map(), + linksWithIndices: [], + clusters: [] as Array<{ key: string; members: number[]; color: string; label: string }>, + }; const nodeIndexMap = new Map(); data.nodes.forEach((n, i) => nodeIndexMap.set(n.id, i)); @@ -242,24 +297,69 @@ export function Constellation({ if (lc > maxLinkCount) maxLinkCount = lc; } + // Clustering precompute: when clusterKeyFn is set, group nodes by key and + // lay each group out around its own centroid (placed on a wider ring) so + // groups read as separate clusters. Without it, nodes use the id-hash ring. + const count = data.nodes.length; + const clusterKeys = clusterKeyFn ? data.nodes.map((n) => clusterKeyFn(n)) : null; + const clusterMembers = new Map(); + if (clusterKeys) { + clusterKeys.forEach((k, i) => { + if (k == null) return; + const arr = clusterMembers.get(k); + if (arr) arr.push(i); + else clusterMembers.set(k, [i]); + }); + } + const clusterOrder = [...clusterMembers.keys()]; + const clusterCentroid = new Map(); + const clusterRingR = Math.sqrt(Math.max(count, 1)) * 60; + clusterOrder.forEach((k, ci) => { + const a = (ci / Math.max(clusterOrder.length, 1)) * Math.PI * 2; + const members = clusterMembers.get(k)!; + const blobR = 22 + Math.sqrt(members.length) * 18; + clusterCentroid.set(k, { + cx: Math.cos(a) * clusterRingR, + cy: Math.sin(a) * clusterRingR, + r: blobR, + }); + }); + // Prepare nodes: assign world positions + pretext-prepare text const nodes: PreparedNode[] = data.nodes.map((node, i) => { const text = node.label || node.id.substring(0, 12); - const color = nodeColorFn?.(node) || node.color || DEFAULT_NODE_COLOR; + const ck = clusterKeys ? clusterKeys[i] : null; + const color = + (ck != null && clusterColorFn?.(ck)) || + nodeColorFn?.(node) || + node.color || + DEFAULT_NODE_COLOR; const lc = linkCounts.get(node.id) || 0; // Use sqrt for a less aggressive curve — avoids everything being red const heat = nodeHeatFn ? heatColor(Math.max(0, Math.min(1, nodeHeatFn(node)))) : heatColor(Math.sqrt(lc / maxLinkCount)); - // Position: use hash of id for deterministic placement, spread in a ring const seed = hashStr(node.id); - const count = data.nodes.length; - const angle = (i / count) * Math.PI * 2 + ((seed % 100) / 100) * 0.5; - const baseRadius = Math.sqrt(count) * 30; - const radius = baseRadius * 0.3 + ((Math.abs(seed) % 1000) / 1000) * baseRadius * 0.7; - const wx = Math.cos(angle) * radius + ((seed % 200) - 100) * 0.5; - const wy = Math.sin(angle) * radius + (((seed >> 8) % 200) - 100) * 0.5; + let wx: number; + let wy: number; + const centroid = ck != null ? clusterCentroid.get(ck) : undefined; + if (centroid) { + // Scatter the node around its cluster centroid in a small disk. + const members = clusterMembers.get(ck!)!; + const j = members.indexOf(i); + const la = (j / Math.max(members.length, 1)) * Math.PI * 2 + ((seed % 100) / 100) * 0.6; + const lr = centroid.r * (0.25 + 0.75 * ((Math.abs(seed) % 1000) / 1000)); + wx = centroid.cx + Math.cos(la) * lr; + wy = centroid.cy + Math.sin(la) * lr; + } else { + // Position: use hash of id for deterministic placement, spread in a ring + const angle = (i / count) * Math.PI * 2 + ((seed % 100) / 100) * 0.5; + const baseRadius = Math.sqrt(count) * 30; + const radius = baseRadius * 0.3 + ((Math.abs(seed) % 1000) / 1000) * baseRadius * 0.7; + wx = Math.cos(angle) * radius + ((seed % 200) - 100) * 0.5; + wy = Math.sin(angle) * radius + (((seed >> 8) % 200) - 100) * 0.5; + } return { node, @@ -268,11 +368,21 @@ export function Constellation({ prepared: prepareWithSegments(text, FONT_SMALL), preparedHeight: prepare(text, FONT_SMALL), color, - heatColor: heat, + // When clustering, the dot itself is tinted by the cluster (scope) color + // so the grouping reads at a glance; otherwise it keeps the heat gradient. + heatColor: centroid ? color : heat, linkCount: lc, }; }); + // Cluster descriptors for the blob overlay (color + label + member indices). + const clusters = clusterOrder.map((key) => ({ + key, + members: clusterMembers.get(key)!, + color: clusterColorFn?.(key) || DEFAULT_NODE_COLOR, + label: clusterLabelFn?.(key) ?? key, + })); + // Build link structures const linksIdx: Array<{ a: number; @@ -307,8 +417,9 @@ export function Constellation({ preparedNodes: nodes, linksByNode: byNode, linksWithIndices: linksIdx, + clusters, }; - }, [data, nodeColorFn, linkColorFn, nodeHeatFn]); + }, [data, nodeColorFn, linkColorFn, nodeHeatFn, clusterKeyFn, clusterColorFn, clusterLabelFn]); // ----- Animation loop ----- const animate = useCallback(() => { @@ -433,6 +544,73 @@ export function Constellation({ ctx.globalAlpha = 1; } + // --- Draw scope cluster blobs (behind nodes) --- + // Wrap each cluster's nodes in a translucent, outward-padded convex hull so + // groups read as soft regions. Tiny clusters (<3 nodes) get a circle instead. + if (clusters.length > 0) { + ctx.save(); + ctx.lineJoin = "round"; + for (const cluster of clusters) { + const pts: Array<[number, number]> = []; + for (const idx of cluster.members) { + if (idx < screenX.length) pts.push([screenX[idx], screenY[idx]]); + } + if (pts.length === 0) continue; + + // Cluster screen centroid (for outward padding + label placement). + let mx = 0; + let my = 0; + for (const [x, y] of pts) { + mx += x; + my += y; + } + mx /= pts.length; + my /= pts.length; + + const pad = 26; + ctx.fillStyle = hexToRgba(cluster.color, isDark ? 0.1 : 0.08); + ctx.strokeStyle = hexToRgba(cluster.color, 0.35); + ctx.lineWidth = 1.25; + ctx.beginPath(); + if (pts.length < 3) { + // Circle around the 1–2 points. + let rad = pad; + for (const [x, y] of pts) rad = Math.max(rad, Math.hypot(x - mx, y - my) + pad); + ctx.arc(mx, my, rad, 0, Math.PI * 2); + } else { + const hull = convexHull(pts); + for (let j = 0; j < hull.length; j++) { + // Push each vertex outward from the centroid for a rounded, padded blob. + const [hx, hy] = hull[j]; + const dx = hx - mx; + const dy = hy - my; + const d = Math.hypot(dx, dy) || 1; + const ex = hx + (dx / d) * pad; + const ey = hy + (dy / d) * pad; + if (j === 0) ctx.moveTo(ex, ey); + else ctx.lineTo(ex, ey); + } + ctx.closePath(); + } + ctx.fill(); + ctx.stroke(); + + // Cluster label at the top of the blob. + if (cluster.label && zoom > 0.4) { + let topY = my; + for (const [, y] of pts) topY = Math.min(topY, y); + ctx.fillStyle = hexToRgba(cluster.color, isDark ? 0.95 : 0.85); + ctx.font = "600 11px ui-sans-serif, system-ui, sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "bottom"; + ctx.fillText(cluster.label, mx, topY - pad - 4); + } + } + ctx.restore(); + ctx.textAlign = "left"; + ctx.textBaseline = "alphabetic"; + } + // --- Draw nodes --- // Label deconfliction grid. Compact mode shrinks the cell so short labels // (e.g. entity names) can pack more densely. @@ -582,26 +760,29 @@ export function Constellation({ legendX -= 14; } - // Heat gradient legend (top-left, below instructions) - ctx.textAlign = "left"; - ctx.font = FONT_BOLD; - ctx.fillStyle = isDark ? "#a1a1aa" : "#52525b"; - ctx.fillText((heatLegendLabel || t("legendLinks")).toUpperCase(), 12, 36); - const [heatLo, heatHi] = heatLegendEndpoints || [t("legendFew"), t("legendMany")]; - // Size the bar so both endpoint labels fit without overlap (e.g. ISO dates - // are wider than "few"/"many"). Min 80px keeps the visual weight stable. - ctx.font = MONO; - const heatLoW = ctx.measureText(heatLo).width; - const heatHiW = ctx.measureText(heatHi).width; - const gradW = Math.max(80, heatLoW + heatHiW + 12); - for (let gx = 0; gx < gradW; gx++) { - ctx.fillStyle = heatColor(gx / gradW); - ctx.fillRect(12 + gx, 42, 1, 6); + // Heat gradient legend (top-left, below instructions). Suppressed while + // clustering, where node color encodes the cluster (scope), not the gradient. + if (clusters.length === 0) { + ctx.textAlign = "left"; + ctx.font = FONT_BOLD; + ctx.fillStyle = isDark ? "#a1a1aa" : "#52525b"; + ctx.fillText((heatLegendLabel || t("legendLinks")).toUpperCase(), 12, 36); + const [heatLo, heatHi] = heatLegendEndpoints || [t("legendFew"), t("legendMany")]; + // Size the bar so both endpoint labels fit without overlap (e.g. ISO dates + // are wider than "few"/"many"). Min 80px keeps the visual weight stable. + ctx.font = MONO; + const heatLoW = ctx.measureText(heatLo).width; + const heatHiW = ctx.measureText(heatHi).width; + const gradW = Math.max(80, heatLoW + heatHiW + 12); + for (let gx = 0; gx < gradW; gx++) { + ctx.fillStyle = heatColor(gx / gradW); + ctx.fillRect(12 + gx, 42, 1, 6); + } + ctx.fillStyle = isDark ? "#a1a1aa" : "#71717a"; + ctx.fillText(heatLo, 12, 60); + ctx.textAlign = "right"; + ctx.fillText(heatHi, 12 + gradW, 60); } - ctx.fillStyle = isDark ? "#a1a1aa" : "#71717a"; - ctx.fillText(heatLo, 12, 60); - ctx.textAlign = "right"; - ctx.fillText(heatHi, 12 + gradW, 60); // Node-size legend — only shown when the caller maps size to a real dimension. // Layout: "few • • ● many" so the labels bracket the dots without overlap. @@ -642,7 +823,7 @@ export function Constellation({ ctx.restore(); animRef.current = requestAnimationFrame(animate); - }, [isDark, preparedNodes, linksWithIndices, linksByNode, t]); + }, [isDark, preparedNodes, linksWithIndices, linksByNode, clusters, t]); // ----- Label drawing helper ----- function drawLabel( diff --git a/hindsight-control-plane/src/components/data-view.tsx b/hindsight-control-plane/src/components/data-view.tsx index 9cc48d4c51..496d460e82 100644 --- a/hindsight-control-plane/src/components/data-view.tsx +++ b/hindsight-control-plane/src/components/data-view.tsx @@ -23,6 +23,7 @@ import { Network, List, Search, + Layers, } from "lucide-react"; import { Table, @@ -47,11 +48,33 @@ 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"; type ViewMode = "graph" | "table" | "timeline" | "constellation"; +// Categorical palette for coloring observation scopes (exact tag sets) when +// "Group by scope" clusters the constellation. Distinct, reasonably separable hues. +const SCOPE_PALETTE = [ + "#0074d9", + "#e11d48", + "#16a34a", + "#f59e0b", + "#8b5cf6", + "#06b6d4", + "#ec4899", + "#65a30d", + "#f97316", + "#6366f1", +]; + +// Stable key for a scope = its tag set, order-normalized (matches the backend's +// normalized scope enumeration so colors are consistent regardless of tag order). +function scopeKeyOf(tags: string[] | undefined): string { + return JSON.stringify([...(tags || [])].sort()); +} + interface DataViewProps { factType: FactType; documentId?: string; @@ -76,6 +99,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); @@ -95,6 +123,8 @@ export function DataView({ occurred_end: t("recencyBasisOccurredEnd"), }; const [recencyBasis, setRecencyBasis] = useState("mentioned_at"); + // Constellation: group observations into per-scope clusters (with colored blobs). + const [groupByScope, setGroupByScope] = useState(false); // Consolidation status for mental models const [consolidationStatus, setConsolidationStatus] = useState<{ @@ -133,10 +163,18 @@ export function DataView({ return () => window.removeEventListener("keydown", handleKeyDown); }, [selectedGraphNode]); - const loadData = async (limit?: number, q?: string, tags?: string[]) => { + // `silent` skips the loading spinner — used by the background consolidation + // poll so the view refreshes in place without flashing. + const loadData = async ( + limit?: number, + q?: string, + tags?: string[], + tagsMatch?: string, + silent = false + ) => { if (!currentBank) return; - setLoading(true); + if (!silent) setLoading(true); try { const graphData: any = await client.getGraph({ bank_id: currentBank, @@ -144,6 +182,7 @@ export function DataView({ limit: limit ?? fetchLimit, q, tags, + tags_match: tagsMatch, document_id: documentId, chunk_id: chunkId, }); @@ -160,7 +199,7 @@ export function DataView({ } catch (error) { // Error toast is shown automatically by the API client interceptor } finally { - setLoading(false); + if (!silent) setLoading(false); } }; @@ -310,6 +349,39 @@ export function DataView({ [recencyLookup] ); + // Assign each distinct observation scope (exact tag set) a stable color from + // the palette, in order of first appearance, for the "Group by scope" clusters. + const scopeColorLookup = useMemo(() => { + if (factType !== "observation" || !data?.table_rows) return null; + const map = new Map(); + let i = 0; + for (const row of data.table_rows as Array<{ tags?: string[] }>) { + const key = scopeKeyOf(row.tags); + if (!map.has(key)) map.set(key, SCOPE_PALETTE[i++ % SCOPE_PALETTE.length]); + } + return map; + }, [factType, data]); + + const scopeClusterKeyFn = useCallback( + (node: GraphNode) => scopeKeyOf(node.metadata?.tags as string[] | undefined), + [] + ); + const scopeClusterColorFn = useCallback( + (key: string) => scopeColorLookup?.get(key) || "#0074d9", + [scopeColorLookup] + ); + const scopeClusterLabelFn = useCallback( + (key: string) => { + try { + const tags = JSON.parse(key) as string[]; + return tags.length ? tags.map((tag) => `#${tag}`).join(" ") : t("scopeGlobal"); + } catch { + return key; + } + }, + [t] + ); + const observationNodeSizeFn = useCallback( (node: GraphNode) => { if (!observationSizeLookup) return 3; @@ -337,30 +409,80 @@ 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]); + + // While consolidation is in progress, poll so the observations + scopes (and + // the "In Sync" badge) refresh live instead of showing a stale, one-shot read + // (bank stats are also cached for up to 60s, so a single fetch can lag well + // behind reality). Silent reloads avoid flashing the spinner. The effect only + // restarts when consolidation starts/stops, not on every tick. + const isConsolidating = + factType === "observation" && (consolidationStatus?.pending_consolidation ?? 0) > 0; + useEffect(() => { + if (!isConsolidating || !currentBank) return; + const id = setInterval(() => { + const { tags, match } = resolveTagQuery(); + loadData(undefined, searchQuery || undefined, tags, match, true); + loadScopes(); + }, 4000); + return () => clearInterval(id); + }, [isConsolidating, currentBank]); + // Enforce 50 node limit to prevent UI instability, default to 20 or max whichever is smaller useEffect(() => { if (data && maxNodes === undefined) { @@ -430,8 +552,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); + }} + /> + )} )} @@ -850,14 +992,29 @@ export function DataView({ linkColorFn={linkColorFn} nodeSizeFn={factType === "observation" ? observationNodeSizeFn : undefined} sizeLegendLabel={factType === "observation" ? t("sourceFactsLabel") : undefined} - nodeHeatFn={recencyLookup ? recencyHeatFn : undefined} + clusterKeyFn={ + factType === "observation" && groupByScope ? scopeClusterKeyFn : undefined + } + clusterColorFn={ + factType === "observation" && groupByScope ? scopeClusterColorFn : undefined + } + clusterLabelFn={ + factType === "observation" && groupByScope ? scopeClusterLabelFn : undefined + } + // When grouping by scope, color encodes scope (not recency), so + // suppress the recency heat to avoid a misleading legend. + nodeHeatFn={ + !(factType === "observation" && groupByScope) && recencyLookup + ? recencyHeatFn + : undefined + } heatLegendLabel={ - recencyLookup + !(factType === "observation" && groupByScope) && recencyLookup ? t("recencyLabel", { basis: RECENCY_BASIS_LABEL[recencyBasis] }) : undefined } heatLegendEndpoints={ - recencyLookup + !(factType === "observation" && groupByScope) && recencyLookup ? [ new Date(recencyLookup.minT).toISOString().slice(0, 10), new Date(recencyLookup.maxT).toISOString().slice(0, 10), @@ -900,24 +1057,39 @@ export function DataView({

{t("constellationViewDescription")}

-
-

- {t("colorBy")} -

- -
+ {factType === "observation" && ( +
+
+ +

+ {t("groupByScope")} +

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

+ {t("colorBy")} +

+ +
+ )}

{t("linkTypes")} diff --git a/hindsight-control-plane/src/components/documents-view.tsx b/hindsight-control-plane/src/components/documents-view.tsx index e630a6148f..ed0b25847e 100644 --- a/hindsight-control-plane/src/components/documents-view.tsx +++ b/hindsight-control-plane/src/components/documents-view.tsx @@ -257,6 +257,29 @@ function MetadataRow({ label, value }: { label: string; value: React.ReactNode } ); } +// Renders the observation_scopes spec a document was retained with: a mode +// keyword ("per_tag" / "combined" / "all_combinations") shown as a mono badge, +// or explicit tag-set lists shown as scope chips. Surfacing this lets you see +// which scoping was requested (e.g. all_combinations on 2 tags → 3 scopes), +// which otherwise only becomes visible once async consolidation finishes. +function ObservationScopesValue({ spec }: { spec: string | string[][] }) { + if (typeof spec === "string") { + return {spec}; + } + return ( +
+ {spec.map((scope, j) => ( + + {scope.length === 0 ? "—" : scope.map((tag) => `#${tag}`).join(" ")} + + ))} +
+ ); +} + const COMPOSITION_COLORS = { world: "#8b5cf6", experience: "#ec4899", @@ -1466,6 +1489,14 @@ export function DocumentsView() { } /> )} + {selectedDocument.observation_scopes && ( + + } + /> + )} void; +} + +/** Stable key for a scope's tag set (order-independent, matches the trigger value). */ +function scopeKey(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} + + ))} + + ); +} + +/** + * 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 selectedKey = value === null ? null : scopeKey(value); + const selectedCount = + value === null ? null : scopes.find((s) => scopeKey(s.tags) === selectedKey)?.count; + + const select = (scope: string[] | null) => { + onChange(scope); + setOpen(false); + }; + + // 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")}; + } + if (value.length === 0) { + return {t("scopeGlobal")}; + } + return {value.map((tag) => `#${tag}`).join(" ")}; + }; + + return ( + + + + + + { + // 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/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..5c2070b2f2 100644 --- a/hindsight-control-plane/src/messages/de.json +++ b/hindsight-control-plane/src/messages/de.json @@ -577,7 +577,13 @@ "sourceFactsLabel": "Quellfakten", "errorLoadingData": "Fehler beim Laden der {factType}-Daten", "all": "Alle", - "memoryComposition": "Speicherzusammensetzung" + "memoryComposition": "Speicherzusammensetzung", + "scopeLabel": "Bereich", + "scopeAll": "Alle Bereiche", + "scopeGlobal": "Global (keine Tags)", + "groupByScope": "Nach Bereich gruppieren", + "scopeSearch": "Bereiche suchen…", + "scopeNoResults": "Keine Bereiche gefunden" }, "documentsView": { "loadingDocuments": "Dokumente werden geladen...", @@ -665,7 +671,8 @@ "importConflictSkip": "Überspringen — vorhandenes Dokument behalten", "importConflictReplace": "Ersetzen — durch das importierte überschreiben", "importConflictNewId": "Beide behalten — unter neuer id importieren", - "textNotStoredWarning": "Der Quelltext wird auf diesem Server nicht gespeichert – nur die extrahierten Erinnerungen werden behalten." + "textNotStoredWarning": "Der Quelltext wird auf diesem Server nicht gespeichert – nur die extrahierten Erinnerungen werden behalten.", + "labelObservationScopes": "Beobachtungsbereiche" }, "entitiesView": { "viewRelations": "Beziehungen", @@ -1267,7 +1274,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 1384c85f06..2f22c0fda1 100644 --- a/hindsight-control-plane/src/messages/en.json +++ b/hindsight-control-plane/src/messages/en.json @@ -577,7 +577,13 @@ "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)", + "groupByScope": "Group by scope", + "scopeSearch": "Search scopes…", + "scopeNoResults": "No scopes found" }, "documentsView": { "loadingDocuments": "Loading documents...", @@ -665,7 +671,8 @@ "importConflictSkip": "Skip — keep the existing document", "importConflictReplace": "Replace — overwrite with the imported one", "importConflictNewId": "Keep both — import under a new id", - "textNotStoredWarning": "Source text isn't stored on this server — only the extracted memories are kept." + "textNotStoredWarning": "Source text isn't stored on this server — only the extracted memories are kept.", + "labelObservationScopes": "Observation scopes" }, "entitiesView": { "viewRelations": "Relations", @@ -1267,7 +1274,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 9fa924b992..3c354d6966 100644 --- a/hindsight-control-plane/src/messages/es.json +++ b/hindsight-control-plane/src/messages/es.json @@ -577,7 +577,13 @@ "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)", + "groupByScope": "Agrupar por ámbito", + "scopeSearch": "Buscar ámbitos…", + "scopeNoResults": "No se encontraron ámbitos" }, "documentsView": { "loadingDocuments": "Cargando documentos...", @@ -665,7 +671,8 @@ "importConflictSkip": "Omitir — conservar el documento existente", "importConflictReplace": "Reemplazar — sobrescribir con el importado", "importConflictNewId": "Conservar ambos — importar con un nuevo id", - "textNotStoredWarning": "El texto original no se almacena en este servidor: solo se conservan las memorias extraídas." + "textNotStoredWarning": "El texto original no se almacena en este servidor: solo se conservan las memorias extraídas.", + "labelObservationScopes": "Ámbitos de observación" }, "entitiesView": { "viewRelations": "Relaciones", @@ -1267,7 +1274,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 c5d31dbc3d..9155844b07 100644 --- a/hindsight-control-plane/src/messages/fr.json +++ b/hindsight-control-plane/src/messages/fr.json @@ -577,7 +577,13 @@ "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)", + "groupByScope": "Grouper par portée", + "scopeSearch": "Rechercher des portées…", + "scopeNoResults": "Aucune portée trouvée" }, "documentsView": { "loadingDocuments": "Chargement des documents...", @@ -665,7 +671,8 @@ "importConflictSkip": "Ignorer — conserver le document existant", "importConflictReplace": "Remplacer — écraser par celui importé", "importConflictNewId": "Conserver les deux — importer sous un nouvel id", - "textNotStoredWarning": "Le texte source n'est pas conservé sur ce serveur — seules les mémoires extraites sont conservées." + "textNotStoredWarning": "Le texte source n'est pas conservé sur ce serveur — seules les mémoires extraites sont conservées.", + "labelObservationScopes": "Portées d'observation" }, "entitiesView": { "viewRelations": "Relations", @@ -1267,7 +1274,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 1ec463f1be..f7d249c621 100644 --- a/hindsight-control-plane/src/messages/ja.json +++ b/hindsight-control-plane/src/messages/ja.json @@ -577,7 +577,13 @@ "sourceFactsLabel": "ソースファクト", "errorLoadingData": "{factType}データの読み込みエラー", "all": "すべて", - "memoryComposition": "メモリ構成" + "memoryComposition": "メモリ構成", + "scopeLabel": "スコープ", + "scopeAll": "すべてのスコープ", + "scopeGlobal": "グローバル(タグなし)", + "groupByScope": "スコープでグループ化", + "scopeSearch": "スコープを検索…", + "scopeNoResults": "スコープが見つかりません" }, "documentsView": { "loadingDocuments": "ドキュメントを読み込み中...", @@ -665,7 +671,8 @@ "importConflictSkip": "スキップ — 既存のドキュメントを保持", "importConflictReplace": "置換 — インポート版で上書き", "importConflictNewId": "両方保持 — 新しい id でインポート", - "textNotStoredWarning": "このサーバーでは元のテキストは保存されません。抽出されたメモリのみが保持されます。" + "textNotStoredWarning": "このサーバーでは元のテキストは保存されません。抽出されたメモリのみが保持されます。", + "labelObservationScopes": "観測スコープ" }, "entitiesView": { "viewRelations": "関係", @@ -1267,7 +1274,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 6bc13cdc00..72c6588485 100644 --- a/hindsight-control-plane/src/messages/ko.json +++ b/hindsight-control-plane/src/messages/ko.json @@ -577,7 +577,13 @@ "sourceFactsLabel": "소스 사실", "errorLoadingData": "{factType} 데이터 불러오기 오류", "all": "전체", - "memoryComposition": "메모리 구성" + "memoryComposition": "메모리 구성", + "scopeLabel": "범위", + "scopeAll": "모든 범위", + "scopeGlobal": "전역(태그 없음)", + "groupByScope": "범위별 그룹화", + "scopeSearch": "범위 검색…", + "scopeNoResults": "범위를 찾을 수 없음" }, "documentsView": { "loadingDocuments": "문서 불러오는 중...", @@ -665,7 +671,8 @@ "importConflictSkip": "건너뛰기 — 기존 문서 유지", "importConflictReplace": "교체 — 가져온 것으로 덮어쓰기", "importConflictNewId": "둘 다 유지 — 새 id로 가져오기", - "textNotStoredWarning": "이 서버에는 원본 텍스트가 저장되지 않습니다. 추출된 메모리만 보관됩니다." + "textNotStoredWarning": "이 서버에는 원본 텍스트가 저장되지 않습니다. 추출된 메모리만 보관됩니다.", + "labelObservationScopes": "관찰 범위" }, "entitiesView": { "viewRelations": "관계", @@ -1267,7 +1274,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 f5a92d58ad..b5ceacccb0 100644 --- a/hindsight-control-plane/src/messages/pt.json +++ b/hindsight-control-plane/src/messages/pt.json @@ -577,7 +577,13 @@ "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)", + "groupByScope": "Agrupar por escopo", + "scopeSearch": "Pesquisar escopos…", + "scopeNoResults": "Nenhum escopo encontrado" }, "documentsView": { "loadingDocuments": "Carregando documentos...", @@ -665,7 +671,8 @@ "importConflictSkip": "Ignorar — manter o documento existente", "importConflictReplace": "Substituir — sobrescrever pelo importado", "importConflictNewId": "Manter ambos — importar com novo id", - "textNotStoredWarning": "O texto de origem não é armazenado neste servidor — apenas as memórias extraídas são mantidas." + "textNotStoredWarning": "O texto de origem não é armazenado neste servidor — apenas as memórias extraídas são mantidas.", + "labelObservationScopes": "Escopos de observação" }, "entitiesView": { "viewRelations": "Relações", @@ -1267,7 +1274,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 077fd1c009..768061422f 100644 --- a/hindsight-control-plane/src/messages/yue-Hant.json +++ b/hindsight-control-plane/src/messages/yue-Hant.json @@ -577,7 +577,13 @@ "sourceFactsLabel": "來源事實", "errorLoadingData": "載入 {factType} 資料時發生錯誤", "all": "全部", - "memoryComposition": "記憶構成" + "memoryComposition": "記憶構成", + "scopeLabel": "範圍", + "scopeAll": "所有範圍", + "scopeGlobal": "全域(冇標籤)", + "groupByScope": "按範圍分組", + "scopeSearch": "搜尋範圍…", + "scopeNoResults": "搵唔到範圍" }, "documentsView": { "loadingDocuments": "載入文件中...", @@ -665,7 +671,8 @@ "importConflictSkip": "略過 — 保留現有文件", "importConflictReplace": "取代 — 以匯入嘅覆寫", "importConflictNewId": "兩者都保留 — 以新 id 匯入", - "textNotStoredWarning": "呢個伺服器唔會儲存原始文字,淨係保留擷取到嘅記憶。" + "textNotStoredWarning": "呢個伺服器唔會儲存原始文字,淨係保留擷取到嘅記憶。", + "labelObservationScopes": "觀測範圍" }, "entitiesView": { "viewRelations": "關係", @@ -1267,7 +1274,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 70c1a2f89e..706c153291 100644 --- a/hindsight-control-plane/src/messages/zh-CN.json +++ b/hindsight-control-plane/src/messages/zh-CN.json @@ -577,7 +577,13 @@ "sourceFactsLabel": "来源事实", "errorLoadingData": "加载 {factType} 数据出错", "all": "全部", - "memoryComposition": "记忆构成" + "memoryComposition": "记忆构成", + "scopeLabel": "范围", + "scopeAll": "所有范围", + "scopeGlobal": "全局(无标签)", + "groupByScope": "按范围分组", + "scopeSearch": "搜索范围…", + "scopeNoResults": "未找到范围" }, "documentsView": { "loadingDocuments": "正在加载文档...", @@ -665,7 +671,8 @@ "importConflictSkip": "跳过 — 保留现有文档", "importConflictReplace": "替换 — 用导入的覆盖", "importConflictNewId": "两者都保留 — 以新 id 导入", - "textNotStoredWarning": "此服务器不存储原始文本,仅保留提取的记忆。" + "textNotStoredWarning": "此服务器不存储原始文本,仅保留提取的记忆。", + "labelObservationScopes": "观测范围" }, "entitiesView": { "viewRelations": "关系", @@ -1267,7 +1274,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 80c07f00a0..25fc89fd46 100644 --- a/hindsight-control-plane/src/messages/zh-TW.json +++ b/hindsight-control-plane/src/messages/zh-TW.json @@ -577,7 +577,13 @@ "sourceFactsLabel": "來源事實", "errorLoadingData": "載入 {factType} 資料時發生錯誤", "all": "全部", - "memoryComposition": "記憶構成" + "memoryComposition": "記憶構成", + "scopeLabel": "範圍", + "scopeAll": "所有範圍", + "scopeGlobal": "全域(無標籤)", + "groupByScope": "按範圍分組", + "scopeSearch": "搜尋範圍…", + "scopeNoResults": "找不到範圍" }, "documentsView": { "loadingDocuments": "正在載入文件…", @@ -665,7 +671,8 @@ "importConflictSkip": "略過 — 保留現有文件", "importConflictReplace": "取代 — 以匯入的覆寫", "importConflictNewId": "兩者都保留 — 以新 id 匯入", - "textNotStoredWarning": "此伺服器不會儲存原始文字,僅保留擷取的記憶。" + "textNotStoredWarning": "此伺服器不會儲存原始文字,僅保留擷取的記憶。", + "labelObservationScopes": "觀測範圍" }, "entitiesView": { "viewRelations": "關係", @@ -1267,7 +1274,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-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..7c70a57839 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": [ @@ -8008,6 +8067,27 @@ ], "title": "Retain Params", "description": "Parameters used during retain" + }, + "observation_scopes": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Observation Scopes", + "description": "The observation_scopes spec configured at retain time (e.g. 'all_combinations', 'per_tag', or explicit tag-set lists), captured into retain_params. None when none was set (default 'combined' scoping) or for documents retained before this was captured." } }, "type": "object", @@ -9698,7 +9778,8 @@ "any", "all", "any_strict", - "all_strict" + "all_strict", + "exact" ] }, { @@ -9844,7 +9925,8 @@ "any", "all", "any_strict", - "all_strict" + "all_strict", + "exact" ] }, { @@ -9923,6 +10005,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 +10530,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 +11188,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 +11769,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..7c70a57839 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": [ @@ -8008,6 +8067,27 @@ ], "title": "Retain Params", "description": "Parameters used during retain" + }, + "observation_scopes": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Observation Scopes", + "description": "The observation_scopes spec configured at retain time (e.g. 'all_combinations', 'per_tag', or explicit tag-set lists), captured into retain_params. None when none was set (default 'combined' scoping) or for documents retained before this was captured." } }, "type": "object", @@ -9698,7 +9778,8 @@ "any", "all", "any_strict", - "all_strict" + "all_strict", + "exact" ] }, { @@ -9844,7 +9925,8 @@ "any", "all", "any_strict", - "all_strict" + "all_strict", + "exact" ] }, { @@ -9923,6 +10005,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 +10530,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 +11188,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 +11769,8 @@ "any", "all", "any_strict", - "all_strict" + "all_strict", + "exact" ], "title": "Match", "default": "any_strict"