diff --git a/hindsight-api/hindsight_api/api/http.py b/hindsight-api/hindsight_api/api/http.py index c93c8f8a..8b4d4703 100644 --- a/hindsight-api/hindsight_api/api/http.py +++ b/hindsight-api/hindsight_api/api/http.py @@ -1922,11 +1922,16 @@ def _register_routes(app: FastAPI): bank_id: str, type: str | None = None, limit: int = 1000, + q: str | None = None, + tags: list[str] | None = Query(None), + tags_match: str = "all_strict", request_context: RequestContext = Depends(get_request_context), ): """Get graph data from database, filtered by bank_id and optionally by type.""" try: - data = await app.state.memory.get_graph_data(bank_id, type, limit=limit, request_context=request_context) + data = await app.state.memory.get_graph_data( + bank_id, type, limit=limit, q=q, tags=tags, tags_match=tags_match, request_context=request_context + ) return data except (AuthenticationError, HTTPException): raise diff --git a/hindsight-api/hindsight_api/engine/memory_engine.py b/hindsight-api/hindsight_api/engine/memory_engine.py index fc0486fe..30d758c1 100644 --- a/hindsight-api/hindsight_api/engine/memory_engine.py +++ b/hindsight-api/hindsight_api/engine/memory_engine.py @@ -3508,6 +3508,9 @@ class MemoryEngine(MemoryEngineInterface): fact_type: str | None = None, *, limit: int = 1000, + q: str | None = None, + tags: list[str] | None = None, + tags_match: str = "all_strict", request_context: "RequestContext", ): """ @@ -3517,6 +3520,9 @@ class MemoryEngine(MemoryEngineInterface): bank_id: Filter by bank ID fact_type: Filter by fact type (world, experience, opinion) limit: Maximum number of items to return (default: 1000) + q: Full-text search query (searches text and context fields) + tags: Filter by tags + tags_match: Tag matching mode (default: all_strict) request_context: Request context for authentication. Returns: @@ -3540,6 +3546,20 @@ class MemoryEngine(MemoryEngineInterface): query_conditions.append(f"fact_type = ${param_count}") query_params.append(fact_type) + if q: + param_count += 1 + query_conditions.append(f"(text ILIKE ${param_count} OR context ILIKE ${param_count})") + query_params.append(f"%{q}%") + + if tags: + from .search.tags import build_tags_where_clause_simple + + tag_clause = build_tags_where_clause_simple(tags, param_count + 1, match=tags_match) + if tag_clause: + query_conditions.append(tag_clause.removeprefix("AND ")) + param_count += 1 + query_params.append(tags) + where_clause = "WHERE " + " AND ".join(query_conditions) if query_conditions else "" # Get total count first @@ -3855,7 +3875,7 @@ class MemoryEngine(MemoryEngineInterface): units = await conn.fetch( f""" - SELECT id, text, event_date, context, fact_type, mentioned_at, occurred_start, occurred_end, chunk_id, proof_count + SELECT id, text, event_date, context, fact_type, mentioned_at, occurred_start, occurred_end, chunk_id, proof_count, tags FROM {fq_table("memory_units")} {where_clause} ORDER BY mentioned_at DESC NULLS LAST, created_at DESC @@ -3908,6 +3928,7 @@ class MemoryEngine(MemoryEngineInterface): "entities": ", ".join(entities) if entities else "", "chunk_id": row["chunk_id"] if row["chunk_id"] else None, "proof_count": row["proof_count"] if row["proof_count"] is not None else 1, + "tags": list(row["tags"]) if row["tags"] else [], } ) diff --git a/hindsight-api/tests/test_graph_filtering.py b/hindsight-api/tests/test_graph_filtering.py new file mode 100644 index 00000000..1490317a --- /dev/null +++ b/hindsight-api/tests/test_graph_filtering.py @@ -0,0 +1,171 @@ +""" +Tests for server-side filtering in the graph API endpoint. + +Verifies that q (text search) and tags filters work correctly +when passed as query parameters to GET /v1/default/banks/{bank_id}/graph. +""" +from datetime import datetime + +import httpx +import pytest +import pytest_asyncio + +from hindsight_api.api import create_app + + +@pytest_asyncio.fixture +async def api_client(memory): + """Create an async test client for the FastAPI app.""" + app = create_app(memory, initialize_memory=False) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + yield client + + +@pytest.fixture +def test_bank_id(): + """Provide a unique bank ID for this test run.""" + return f"graph_filter_test_{datetime.now().timestamp()}" + + +@pytest.mark.asyncio +async def test_graph_no_filter_returns_all(api_client, test_bank_id): + """Without filters the graph endpoint returns all memories.""" + response = await api_client.post( + f"/v1/default/banks/{test_bank_id}/memories", + json={ + "items": [ + {"content": "Alice loves hiking in the mountains.", "tags": ["user_alice"]}, + {"content": "Bob enjoys swimming at the beach.", "tags": ["user_bob"]}, + ] + }, + ) + assert response.status_code == 200 + + response = await api_client.get(f"/v1/default/banks/{test_bank_id}/graph") + assert response.status_code == 200 + data = response.json() + assert "table_rows" in data + texts = [row["text"] for row in data["table_rows"]] + assert any("Alice" in t for t in texts) + assert any("Bob" in t for t in texts) + + +@pytest.mark.asyncio +async def test_graph_q_filter_returns_matching(api_client, test_bank_id): + """The q parameter filters memories by text content.""" + response = await api_client.post( + f"/v1/default/banks/{test_bank_id}/memories", + json={ + "items": [ + {"content": "Alice loves hiking in the mountains."}, + {"content": "Bob enjoys swimming at the beach."}, + ] + }, + ) + assert response.status_code == 200 + + response = await api_client.get(f"/v1/default/banks/{test_bank_id}/graph", params={"q": "Alice"}) + assert response.status_code == 200 + data = response.json() + texts = [row["text"] for row in data["table_rows"]] + assert all("Alice" in t or "alice" in t.lower() for t in texts), ( + f"Expected only Alice memories, got: {texts}" + ) + assert not any("Bob" in t for t in texts) + + +@pytest.mark.asyncio +async def test_graph_q_filter_case_insensitive(api_client, test_bank_id): + """The q filter is case-insensitive.""" + response = await api_client.post( + f"/v1/default/banks/{test_bank_id}/memories", + json={ + "items": [ + {"content": "Alice loves hiking in the mountains."}, + {"content": "Bob enjoys swimming at the beach."}, + ] + }, + ) + assert response.status_code == 200 + + response = await api_client.get(f"/v1/default/banks/{test_bank_id}/graph", params={"q": "alice"}) + assert response.status_code == 200 + data = response.json() + texts = [row["text"] for row in data["table_rows"]] + assert any("Alice" in t for t in texts) + assert not any("Bob" in t for t in texts) + + +@pytest.mark.asyncio +async def test_graph_tags_filter_returns_matching(api_client, test_bank_id): + """The tags parameter filters memories to only those with matching tags.""" + response = await api_client.post( + f"/v1/default/banks/{test_bank_id}/memories", + json={ + "items": [ + {"content": "Alice loves hiking.", "tags": ["user_alice"]}, + {"content": "Bob enjoys swimming.", "tags": ["user_bob"]}, + ] + }, + ) + assert response.status_code == 200 + + response = await api_client.get( + f"/v1/default/banks/{test_bank_id}/graph", + params={"tags": "user_alice", "tags_match": "all_strict"}, + ) + assert response.status_code == 200 + data = response.json() + texts = [row["text"] for row in data["table_rows"]] + assert any("Alice" in t for t in texts) + assert not any("Bob" in t for t in texts) + + +@pytest.mark.asyncio +async def test_graph_q_and_tags_filter_combined(api_client, test_bank_id): + """Combining q and tags filters applies both server-side.""" + response = await api_client.post( + f"/v1/default/banks/{test_bank_id}/memories", + json={ + "items": [ + {"content": "Alice loves hiking.", "tags": ["user_alice"]}, + {"content": "Alice also loves coding.", "tags": ["user_alice"]}, + {"content": "Bob enjoys swimming.", "tags": ["user_bob"]}, + ] + }, + ) + assert response.status_code == 200 + + response = await api_client.get( + f"/v1/default/banks/{test_bank_id}/graph", + params={"q": "hiking", "tags": "user_alice", "tags_match": "all_strict"}, + ) + assert response.status_code == 200 + data = response.json() + texts = [row["text"] for row in data["table_rows"]] + assert any("hiking" in t.lower() for t in texts) + assert not any("coding" in t.lower() for t in texts) + assert not any("Bob" in t for t in texts) + + +@pytest.mark.asyncio +async def test_graph_q_filter_empty_results(api_client, test_bank_id): + """The q filter returns empty results when no memory matches.""" + response = await api_client.post( + f"/v1/default/banks/{test_bank_id}/memories", + json={ + "items": [ + {"content": "Alice loves hiking."}, + ] + }, + ) + assert response.status_code == 200 + + response = await api_client.get( + f"/v1/default/banks/{test_bank_id}/graph", + params={"q": "zzznomatchzzz"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["table_rows"] == [] diff --git a/hindsight-api/tests/test_tags_visibility.py b/hindsight-api/tests/test_tags_visibility.py index adf68c0f..088f54f5 100644 --- a/hindsight-api/tests/test_tags_visibility.py +++ b/hindsight-api/tests/test_tags_visibility.py @@ -890,3 +890,40 @@ async def test_list_tags_ordered_by_count(api_client): # common (3) should come before medium (2) which should come before rare (1) assert tags.index("common") < tags.index("medium") assert tags.index("medium") < tags.index("rare") + + +@pytest.mark.asyncio +async def test_list_memories_includes_tags(api_client, test_bank_id): + """Test that list memories endpoint returns tags for each memory unit. + + Regression test: tags were previously omitted from the SELECT query in + list_memory_units, causing the memory dialog in the UI to show no tags + even when memories had been stored with tags. + """ + tags = ["user_alice", "session_xyz", "project_alpha", "team_eng", "env_prod", "region_us"] + + response = await api_client.post( + f"/v1/default/banks/{test_bank_id}/memories", + json={ + "items": [ + { + "content": "Alice is a senior engineer on the platform team.", + "tags": tags, + } + ] + }, + ) + assert response.status_code == 200 + + # List memories and verify all tags are returned + response = await api_client.get(f"/v1/default/banks/{test_bank_id}/memories/list") + assert response.status_code == 200 + result = response.json() + + assert result["total"] > 0 + memory_item = next((item for item in result["items"] if "Alice" in item["text"]), None) + assert memory_item is not None, "Should find the stored memory" + assert "tags" in memory_item, "Memory item must include a 'tags' field" + assert set(memory_item["tags"]) == set(tags), ( + f"All {len(tags)} tags should be returned, got: {memory_item['tags']}" + ) diff --git a/hindsight-cli/src/api.rs b/hindsight-cli/src/api.rs index 772324b4..2aa23acf 100644 --- a/hindsight-cli/src/api.rs +++ b/hindsight-cli/src/api.rs @@ -452,7 +452,7 @@ impl ApiClient { _verbose: bool, ) -> Result { self.runtime.block_on(async { - let response = self.client.get_graph(bank_id, limit, type_filter, None).await?; + let response = self.client.get_graph(bank_id, limit, type_filter, None, None, None, None).await?; Ok(response.into_inner()) }) } diff --git a/hindsight-clients/go/api/openapi.yaml b/hindsight-clients/go/api/openapi.yaml index 365c7ead..d558ea96 100644 --- a/hindsight-clients/go/api/openapi.yaml +++ b/hindsight-clients/go/api/openapi.yaml @@ -83,6 +83,34 @@ paths: title: Limit type: integer style: form + - explode: true + in: query + name: q + required: false + schema: + nullable: true + type: string + style: form + - explode: true + in: query + name: tags + required: false + schema: + items: + nullable: true + type: string + nullable: true + type: array + style: form + - explode: true + in: query + name: tags_match + required: false + schema: + default: all_strict + title: Tags Match + type: string + style: form - explode: false in: header name: authorization diff --git a/hindsight-clients/go/api_memory.go b/hindsight-clients/go/api_memory.go index 4cc49d7e..1ce3185c 100644 --- a/hindsight-clients/go/api_memory.go +++ b/hindsight-clients/go/api_memory.go @@ -17,6 +17,7 @@ import ( "net/http" "net/url" "strings" + "reflect" ) @@ -287,6 +288,9 @@ type ApiGetGraphRequest struct { bankId string type_ *string limit *int32 + q *string + tags *[]*string + tagsMatch *string authorization *string } @@ -300,6 +304,21 @@ func (r ApiGetGraphRequest) Limit(limit int32) ApiGetGraphRequest { return r } +func (r ApiGetGraphRequest) Q(q string) ApiGetGraphRequest { + r.q = &q + return r +} + +func (r ApiGetGraphRequest) Tags(tags []*string) ApiGetGraphRequest { + r.tags = &tags + return r +} + +func (r ApiGetGraphRequest) TagsMatch(tagsMatch string) ApiGetGraphRequest { + r.tagsMatch = &tagsMatch + return r +} + func (r ApiGetGraphRequest) Authorization(authorization string) ApiGetGraphRequest { r.authorization = &authorization return r @@ -357,6 +376,26 @@ func (a *MemoryAPIService) GetGraphExecute(r ApiGetGraphRequest) (*GraphDataResp var defaultValue int32 = 1000 r.limit = &defaultValue } + if r.q != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "q", r.q, "form", "") + } + if r.tags != nil { + t := *r.tags + if reflect.TypeOf(t).Kind() == reflect.Slice { + s := reflect.ValueOf(t) + for i := 0; i < s.Len(); i++ { + parameterAddToHeaderOrQuery(localVarQueryParams, "tags", s.Index(i).Interface(), "form", "multi") + } + } else { + parameterAddToHeaderOrQuery(localVarQueryParams, "tags", t, "form", "multi") + } + } + if r.tagsMatch != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "tags_match", r.tagsMatch, "form", "") + } else { + var defaultValue string = "all_strict" + r.tagsMatch = &defaultValue + } // to determine the Content-Type header localVarHTTPContentTypes := []string{} 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 21814b28..6e662397 100644 --- a/hindsight-clients/python/hindsight_client_api/api/memory_api.py +++ b/hindsight-clients/python/hindsight_client_api/api/memory_api.py @@ -17,7 +17,7 @@ from typing import Any, Dict, List, Optional, Tuple, Union from typing_extensions import Annotated from pydantic import Field, StrictInt, StrictStr -from typing import Any, Optional +from typing import Any, List, Optional from typing_extensions import Annotated from hindsight_client_api.models.clear_memory_observations_response import ClearMemoryObservationsResponse from hindsight_client_api.models.delete_response import DeleteResponse @@ -643,6 +643,9 @@ class MemoryApi: bank_id: StrictStr, type: Optional[StrictStr] = None, limit: Optional[StrictInt] = None, + q: Optional[StrictStr] = None, + tags: Optional[List[Optional[StrictStr]]] = None, + tags_match: Optional[StrictStr] = None, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -667,6 +670,12 @@ class MemoryApi: :type type: str :param limit: :type limit: int + :param q: + :type q: str + :param tags: + :type tags: List[Optional[str]] + :param tags_match: + :type tags_match: str :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -695,6 +704,9 @@ class MemoryApi: bank_id=bank_id, type=type, limit=limit, + q=q, + tags=tags, + tags_match=tags_match, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -723,6 +735,9 @@ class MemoryApi: bank_id: StrictStr, type: Optional[StrictStr] = None, limit: Optional[StrictInt] = None, + q: Optional[StrictStr] = None, + tags: Optional[List[Optional[StrictStr]]] = None, + tags_match: Optional[StrictStr] = None, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -747,6 +762,12 @@ class MemoryApi: :type type: str :param limit: :type limit: int + :param q: + :type q: str + :param tags: + :type tags: List[Optional[str]] + :param tags_match: + :type tags_match: str :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -775,6 +796,9 @@ class MemoryApi: bank_id=bank_id, type=type, limit=limit, + q=q, + tags=tags, + tags_match=tags_match, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -803,6 +827,9 @@ class MemoryApi: bank_id: StrictStr, type: Optional[StrictStr] = None, limit: Optional[StrictInt] = None, + q: Optional[StrictStr] = None, + tags: Optional[List[Optional[StrictStr]]] = None, + tags_match: Optional[StrictStr] = None, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -827,6 +854,12 @@ class MemoryApi: :type type: str :param limit: :type limit: int + :param q: + :type q: str + :param tags: + :type tags: List[Optional[str]] + :param tags_match: + :type tags_match: str :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -855,6 +888,9 @@ class MemoryApi: bank_id=bank_id, type=type, limit=limit, + q=q, + tags=tags, + tags_match=tags_match, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -878,6 +914,9 @@ class MemoryApi: bank_id, type, limit, + q, + tags, + tags_match, authorization, _request_auth, _content_type, @@ -888,6 +927,7 @@ class MemoryApi: _host = None _collection_formats: Dict[str, str] = { + 'tags': 'multi', } _path_params: Dict[str, str] = {} @@ -911,6 +951,18 @@ class MemoryApi: _query_params.append(('limit', limit)) + if q is not None: + + _query_params.append(('q', q)) + + if tags is not None: + + _query_params.append(('tags', tags)) + + if tags_match is not None: + + _query_params.append(('tags_match', tags_match)) + # process the header parameters if authorization is not None: _header_params['authorization'] = authorization diff --git a/hindsight-clients/typescript/generated/types.gen.ts b/hindsight-clients/typescript/generated/types.gen.ts index 2125fb17..dfbdd20e 100644 --- a/hindsight-clients/typescript/generated/types.gen.ts +++ b/hindsight-clients/typescript/generated/types.gen.ts @@ -2165,6 +2165,18 @@ export type GetGraphData = { * Limit */ limit?: number; + /** + * Q + */ + q?: string | null; + /** + * Tags + */ + tags?: Array | null; + /** + * Tags Match + */ + tags_match?: string; }; url: "/v1/default/banks/{bank_id}/graph"; }; diff --git a/hindsight-control-plane/src/app/api/graph/route.ts b/hindsight-control-plane/src/app/api/graph/route.ts index dead8337..17b2d157 100644 --- a/hindsight-control-plane/src/app/api/graph/route.ts +++ b/hindsight-control-plane/src/app/api/graph/route.ts @@ -14,6 +14,8 @@ export async function GET(request: NextRequest) { const type = searchParams.get("type") || searchParams.get("fact_type") || undefined; const limitParam = searchParams.get("limit"); const limit = limitParam ? parseInt(limitParam, 10) : undefined; + const q = searchParams.get("q") || undefined; + const tags = searchParams.getAll("tags"); const response = await sdk.getGraph({ client: lowLevelClient, @@ -21,6 +23,9 @@ export async function GET(request: NextRequest) { query: { type: type, limit: limit, + q, + tags: tags.length > 0 ? tags : undefined, + tags_match: tags.length > 0 ? "all_strict" : undefined, }, }); diff --git a/hindsight-control-plane/src/components/data-view.tsx b/hindsight-control-plane/src/components/data-view.tsx index 9629ade5..5fcec862 100644 --- a/hindsight-control-plane/src/components/data-view.tsx +++ b/hindsight-control-plane/src/components/data-view.tsx @@ -21,6 +21,9 @@ import { Clock, Network, List, + Search, + Tag, + X, } from "lucide-react"; import { Table, @@ -50,6 +53,8 @@ export function DataView({ factType }: DataViewProps) { const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + const [tagFilters, setTagFilters] = useState([]); + const [tagInput, setTagInput] = useState(""); const [currentPage, setCurrentPage] = useState(1); const [selectedGraphNode, setSelectedGraphNode] = useState(null); const [modalMemoryId, setModalMemoryId] = useState(null); @@ -95,7 +100,7 @@ export function DataView({ factType }: DataViewProps) { return () => window.removeEventListener("keydown", handleKeyDown); }, [selectedGraphNode]); - const loadData = async (limit?: number) => { + const loadData = async (limit?: number, q?: string, tags?: string[]) => { if (!currentBank) return; setLoading(true); @@ -104,6 +109,8 @@ export function DataView({ factType }: DataViewProps) { bank_id: currentBank, type: factType, limit: limit ?? fetchLimit, + q, + tags, }); setData(graphData); @@ -122,19 +129,22 @@ export function DataView({ factType }: DataViewProps) { } }; - // Filter table rows based on search query (text only) + const addTagFilter = (tag: string) => { + const trimmed = tag.trim(); + if (trimmed && !tagFilters.includes(trimmed)) { + setTagFilters((prev) => [...prev, trimmed]); + } + setTagInput(""); + }; + + const removeTagFilter = (tag: string) => { + setTagFilters((prev) => prev.filter((t) => t !== tag)); + }; + + // Table rows are already filtered server-side const filteredTableRows = useMemo(() => { - if (!data?.table_rows) return []; - if (!searchQuery) return data.table_rows; - - const query = searchQuery.toLowerCase(); - return data.table_rows.filter((row: any) => row.text?.toLowerCase().includes(query)); - }, [data, searchQuery]); - - // Get filtered node IDs for graph filtering - const filteredNodeIds = useMemo(() => { - return new Set(filteredTableRows.map((row: any) => row.id)); - }, [filteredTableRows]); + return data?.table_rows ?? []; + }, [data]); // Helper to get normalized link type const getLinkTypeCategory = (type: string | undefined): string => { @@ -144,32 +154,19 @@ export function DataView({ factType }: DataViewProps) { return "semantic"; }; - // Convert data for Graph2D with filtering + // Convert data for Graph2D (graph data is already filtered server-side) const graph2DData = useMemo(() => { if (!data) return { nodes: [], links: [] }; const fullData = convertHindsightGraphData(data); - let nodes = fullData.nodes; - let links = fullData.links; - - // Filter nodes based on search query - if (searchQuery) { - const filteredNodes = fullData.nodes.filter((node) => filteredNodeIds.has(node.id)); - const filteredNodeIdSet = new Set(filteredNodes.map((n) => n.id)); - nodes = filteredNodes; - links = fullData.links.filter( - (link) => filteredNodeIdSet.has(link.source) && filteredNodeIdSet.has(link.target) - ); - } - // Filter links based on visible link types - links = links.filter((link) => { + const links = fullData.links.filter((link) => { const category = getLinkTypeCategory(link.type); return visibleLinkTypes.has(category); }); - return { nodes, links }; - }, [data, searchQuery, filteredNodeIds, visibleLinkTypes]); + return { nodes: fullData.nodes, links }; + }, [data, visibleLinkTypes]); // Calculate link stats for display const linkStats = useMemo(() => { @@ -227,11 +224,40 @@ export function DataView({ factType }: DataViewProps) { return "#0074d9"; // Brand primary blue for semantic }, []); - // Reset to first page when search query changes + // Reset to first page when filters change useEffect(() => { setCurrentPage(1); + }, [searchQuery, tagFilters]); + + // Debounce ref for text search + const searchDebounceRef = useRef | null>(null); + + // Trigger server-side reload when text filter changes (debounced 300ms) + useEffect(() => { + if (searchDebounceRef.current) { + clearTimeout(searchDebounceRef.current); + } + searchDebounceRef.current = setTimeout(() => { + if (currentBank) { + loadData( + undefined, + searchQuery || undefined, + tagFilters.length > 0 ? tagFilters : undefined + ); + } + }, 300); + return () => { + if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); + }; }, [searchQuery]); + // Trigger server-side reload immediately when tag filters change + useEffect(() => { + if (currentBank) { + loadData(undefined, searchQuery || undefined, tagFilters.length > 0 ? tagFilters : undefined); + } + }, [tagFilters]); + // Auto-load data when component mounts or factType/currentBank changes useEffect(() => { if (currentBank) { @@ -261,22 +287,68 @@ export function DataView({ factType }: DataViewProps) { ) : data ? ( <> - {/* Always visible filter */} -
- setSearchQuery(e.target.value)} - placeholder="Filter memories by text..." - className="max-w-md" - /> + {/* Always visible filters */} +
+
+ {/* Text search */} +
+ + setSearchQuery(e.target.value)} + placeholder="Filter by text or context..." + className="pl-8 h-9" + /> +
+ {/* Tag input */} +
+ + setTagInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + addTagFilter(tagInput); + } else if (e.key === "Backspace" && !tagInput && tagFilters.length > 0) { + removeTagFilter(tagFilters[tagFilters.length - 1]); + } + }} + placeholder="Filter by tag…" + className="pl-8 h-9" + /> +
+
+ {/* Active tag chips */} + {tagFilters.length > 0 && ( +
+ {tagFilters.map((tag) => ( + + # + {tag} + + + ))} +
+ )}
- {searchQuery ? ( - `${filteredTableRows.length} of ${data.table_rows?.length ?? 0} loaded memories` + {searchQuery || tagFilters.length > 0 ? ( + `${filteredTableRows.length} matching memories` ) : data.table_rows?.length < data.total_units ? ( Showing {data.table_rows?.length ?? 0} of {data.total_units} total memories diff --git a/hindsight-control-plane/src/components/memory-detail-modal.tsx b/hindsight-control-plane/src/components/memory-detail-modal.tsx index e602f3d2..35a436f7 100644 --- a/hindsight-control-plane/src/components/memory-detail-modal.tsx +++ b/hindsight-control-plane/src/components/memory-detail-modal.tsx @@ -5,7 +5,8 @@ import { client } from "@/lib/api"; import { useBank } from "@/lib/bank-context"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Loader2, Calendar, Tag, Users, FileText, Layers } from "lucide-react"; +import { Loader2, Calendar, Users, FileText, Layers } from "lucide-react"; +import { TagList } from "@/components/ui/tag-list"; import { Button } from "@/components/ui/button"; interface SourceMemory { @@ -212,24 +213,7 @@ export function MemoryDetailModal({ memoryId, onClose }: MemoryDetailModalProps) )} {/* Tags */} - {memory.tags && memory.tags.length > 0 && ( -
-
- - Tags -
-
- {memory.tags.map((tag, idx) => ( - - {tag} - - ))} -
-
- )} + {/* Source Memories */} {memory.source_memories && memory.source_memories.length > 0 && ( @@ -407,24 +391,7 @@ export function MemoryDetailModal({ memoryId, onClose }: MemoryDetailModalProps) )} {/* Tags */} - {memory.tags && memory.tags.length > 0 && ( -
-
- - Tags -
-
- {memory.tags.map((tag, idx) => ( - - {tag} - - ))} -
-
- )} + {/* ID */}
@@ -544,6 +511,12 @@ export function MemoryDetailModal({ memoryId, onClose }: MemoryDetailModalProps) )} + {document.tags && document.tags.length > 0 && ( +
+ +
+ )} +
Document ID diff --git a/hindsight-control-plane/src/components/memory-detail-panel.tsx b/hindsight-control-plane/src/components/memory-detail-panel.tsx index 672ada05..628102d8 100644 --- a/hindsight-control-plane/src/components/memory-detail-panel.tsx +++ b/hindsight-control-plane/src/components/memory-detail-panel.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; +import { TagList } from "@/components/ui/tag-list"; import { Copy, Check, X, Loader2, Calendar } from "lucide-react"; import { DocumentChunkModal } from "./document-chunk-modal"; import { MemoryDetailModal } from "./memory-detail-modal"; @@ -208,21 +209,7 @@ export function MemoryDetailPanel({ )} {/* Tags */} - {displayMemory.tags && displayMemory.tags.length > 0 && ( -
-
Tags
-
- {displayMemory.tags.map((tag: string, i: number) => ( - - {tag} - - ))} -
-
- )} + {/* Source Memories (for observations) */} {displayMemory.source_memories && displayMemory.source_memories.length > 0 && ( @@ -472,16 +459,7 @@ export function MemoryDetailPanel({
Tags
-
- {displayMemory.tags.map((tag: string, i: number) => ( - - {tag} - - ))} -
+
)} diff --git a/hindsight-control-plane/src/components/ui/tag-list.tsx b/hindsight-control-plane/src/components/ui/tag-list.tsx new file mode 100644 index 00000000..fcb3aee5 --- /dev/null +++ b/hindsight-control-plane/src/components/ui/tag-list.tsx @@ -0,0 +1,46 @@ +import { Tag } from "lucide-react"; + +interface TagListProps { + tags: string[]; + /** Visual size variant. Defaults to "sm". */ + size?: "xs" | "sm" | "md"; + /** Show the "Tags" section label above the chips. Defaults to false. */ + showLabel?: boolean; +} + +/** + * Renders a list of tags as branded chips using Hindsight's primary color. + * Returns null when the tags array is empty or undefined. + */ +export function TagList({ tags, size = "sm", showLabel = false }: TagListProps) { + if (!tags || tags.length === 0) return null; + + const chipClass = + size === "xs" + ? "text-[10px] px-1.5 py-0.5 rounded gap-0.5" + : size === "md" + ? "text-sm px-3 py-1 rounded-full gap-1 font-medium" + : "text-xs px-2 py-0.5 rounded-md gap-1 font-medium"; + + return ( +
+ {showLabel && ( +
+ + Tags +
+ )} +
+ {tags.map((tag, idx) => ( + + # + {tag} + + ))} +
+
+ ); +} diff --git a/hindsight-control-plane/src/lib/api.ts b/hindsight-control-plane/src/lib/api.ts index 29fbf309..11ab2e0e 100644 --- a/hindsight-control-plane/src/lib/api.ts +++ b/hindsight-control-plane/src/lib/api.ts @@ -189,11 +189,21 @@ export class ControlPlaneClient { /** * Get graph data */ - async getGraph(params: { bank_id: string; type?: string; limit?: number }) { + async getGraph(params: { + bank_id: string; + type?: string; + limit?: number; + q?: string; + tags?: string[]; + }) { const queryParams = new URLSearchParams(); queryParams.append("bank_id", params.bank_id); if (params.type) queryParams.append("type", params.type); if (params.limit) queryParams.append("limit", params.limit.toString()); + if (params.q) queryParams.append("q", params.q); + if (params.tags && params.tags.length > 0) { + params.tags.forEach((tag) => queryParams.append("tags", tag)); + } return this.fetchApi(`/api/graph?${queryParams}`); } diff --git a/hindsight-docs/docs/developer/api/mental-models.mdx b/hindsight-docs/docs/developer/api/mental-models.mdx index 1bdcc5ab..8873ac54 100644 --- a/hindsight-docs/docs/developer/api/mental-models.mdx +++ b/hindsight-docs/docs/developer/api/mental-models.mdx @@ -244,6 +244,34 @@ curl -X DELETE "http://localhost:8888/v1/default/banks/my-bank/mental-models/{me --- +## Tags and Visibility + +Mental models support the same tag system as memories. When you assign tags to a mental model, those tags control both **which memories it reads** during refresh and **when it is surfaced** during reflect. + +### How tags affect mental model refresh + +When a mental model is refreshed (manually or automatically), it runs an internal reflect call to regenerate its content. If the mental model has tags, that reflect call uses `all_strict` tag matching — meaning it will only read memories that carry **all** of the mental model's tags. Untagged memories are excluded. + +``` +Mental model tags: ["user:alice"] + +During refresh, it reads: + ✅ "Alice prefers async communication" — has "user:alice" + ✅ "Team uses Slack for announcements" — has "user:alice" (plus other tags) + ❌ "Company policy: no meetings on Fridays" — untagged, excluded + ❌ "Bob dislikes long meetings" — no "user:alice" tag +``` + +This means a mental model tagged `["user:alice"]` will also pick up memories tagged `["user:alice", "team"]` — extra tags on a memory don't disqualify it. Only the mental model's own tags are required to be present. + +### How tags affect mental model lookup during reflect + +When you call `reflect` with tags, those same tags are used to filter which mental models the agent can see. A mental model is visible only if its tags overlap with the tags on the reflect request. + +For more details on tag matching modes (`any`, `any_strict`, `all`, `all_strict`) and worked examples, see the [Recall tags reference](./recall#tags). + +--- + ## Use Cases | Use Case | Example | diff --git a/hindsight-docs/docs/developer/api/recall.mdx b/hindsight-docs/docs/developer/api/recall.mdx index 3433c43a..a6ef4f4b 100644 --- a/hindsight-docs/docs/developer/api/recall.mdx +++ b/hindsight-docs/docs/developer/api/recall.mdx @@ -130,28 +130,59 @@ Filters recall to only memories that match the specified tags. When omitted, all The `tags_match` parameter controls the filtering logic: -- `any` (default) — memory matches if it has at least one of the specified tags, or has no tags at all. Use this for "user-specific + shared global" patterns. -- `any_strict` — memory matches if it has at least one of the specified tags, and untagged memories are excluded. Use this when you want only explicitly scoped memories. -- `all` — memory matches if it has every specified tag, or has no tags at all. -- `all_strict` — memory matches if it has every specified tag, and untagged memories are excluded. +| Mode | Untagged memories | Match condition | +|------|-------------------|-----------------| +| `any` (default) | Included | Memory has **at least one** of the specified tags | +| `any_strict` | Excluded | Memory has **at least one** of the specified tags | +| `all` | Included | Memory has **all** of the specified tags | +| `all_strict` | Excluded | Memory has **all** of the specified tags | - - - - - +#### Scenario setup - - - - - +Consider a bank with these four memories: - - - - - +| Memory | Tags | +|--------|------| +| "Alice prefers async communication" | `["user:alice"]` | +| "Bob dislikes long meetings" | `["user:bob"]` | +| "Team uses Slack for announcements" | `["user:alice", "team"]` | +| "Company policy: no meetings on Fridays" | *(untagged)* | + +#### `any` — OR matching, includes untagged (default) + +Returns memories that have **at least one** matching tag, plus untagged memories. + + + +Use this for **shared global knowledge + user-specific** patterns, where untagged memories represent information everyone should see. + +#### `any_strict` — OR matching, excludes untagged + +Same as `any` but untagged memories are excluded. + + + +Use this when memories are **fully partitioned by tags** and untagged memories should never be visible. + +#### `all` — AND matching, includes untagged + +Returns memories that have **every** specified tag, plus untagged memories. + + + +Use this when memories must belong to a **specific intersection** of scopes (e.g., only memories relevant to both a user and a project), while still surfacing shared global knowledge. + +#### `all_strict` — AND matching, excludes untagged + +Returns memories that have **every** specified tag, and excludes untagged memories. + + + +Use this for strict scope enforcement where a memory must explicitly belong to **all** specified contexts. + +:::tip Extra tags are fine +A memory with tags `["user:alice", "team", "project:x"]` will still match a filter of `["user:alice", "team"]` under `all_strict` — extra tags on the memory are not a problem. The filter only requires the memory to contain **at least** the specified tags. +::: ### trace diff --git a/hindsight-docs/examples/api/recall.py b/hindsight-docs/examples/api/recall.py index c0b04f57..f8789df0 100644 --- a/hindsight-docs/examples/api/recall.py +++ b/hindsight-docs/examples/api/recall.py @@ -186,6 +186,66 @@ response = client.recall( # [/docs:recall-tags-all] +# [docs:recall-tags-any] +response = client.recall( + bank_id="my-bank", + query="communication preferences", + tags=["user:alice"], + tags_match="any", # default +) +# Returns: +# [match] "Alice prefers async communication" — has "user:alice" +# [no match] "Bob dislikes long meetings" — no overlap with ["user:alice"] +# [match] "Team uses Slack for announcements" — has "user:alice" +# [match] "Company policy: no meetings on Fridays" — untagged, included by default +# [/docs:recall-tags-any] + + +# [docs:recall-tags-any-strict] +response = client.recall( + bank_id="my-bank", + query="communication preferences", + tags=["user:alice"], + tags_match="any_strict", +) +# Returns: +# [match] "Alice prefers async communication" — has "user:alice" +# [no match] "Bob dislikes long meetings" — no overlap with ["user:alice"] +# [match] "Team uses Slack for announcements" — has "user:alice" +# [no match] "Company policy: no meetings on Fridays" — untagged, excluded +# [/docs:recall-tags-any-strict] + + +# [docs:recall-tags-all-mode] +response = client.recall( + bank_id="my-bank", + query="communication tools", + tags=["user:alice", "team"], + tags_match="all", +) +# Returns: +# [no match] "Alice prefers async communication" — missing "team" +# [no match] "Bob dislikes long meetings" — missing both tags +# [match] "Team uses Slack for announcements" — has both "user:alice" and "team" +# [match] "Company policy: no meetings on Fridays" — untagged, included by default +# [/docs:recall-tags-all-mode] + + +# [docs:recall-tags-all-strict] +response = client.recall( + bank_id="my-bank", + query="communication tools", + tags=["user:alice", "team"], + tags_match="all_strict", +) +# Returns: +# [no match] "Alice prefers async communication" — missing "team" +# [no match] "Bob dislikes long meetings" — missing both tags +# [match] "Team uses Slack for announcements" — has both "user:alice" and "team" +# [no match] "Company policy: no meetings on Fridays" — untagged, excluded +# [/docs:recall-tags-all-strict] + + # ============================================================================= # Legacy snippets for v0.3 docs (kept for backward compatibility) # ============================================================================= diff --git a/hindsight-docs/static/openapi.json b/hindsight-docs/static/openapi.json index c0ab6f3f..0bf46f55 100644 --- a/hindsight-docs/static/openapi.json +++ b/hindsight-docs/static/openapi.json @@ -119,6 +119,51 @@ "title": "Limit" } }, + { + "name": "q", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Q" + } + }, + { + "name": "tags", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "title": "Tags" + } + }, + { + "name": "tags_match", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "all_strict", + "title": "Tags Match" + } + }, { "name": "authorization", "in": "header",