Compare commits

...
19 changed files with 741 additions and 128 deletions
+6 -1
View File
@@ -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
@@ -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 [],
}
)
+171
View File
@@ -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"] == []
@@ -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']}"
)
+1 -1
View File
@@ -452,7 +452,7 @@ impl ApiClient {
_verbose: bool,
) -> Result<types::GraphDataResponse> {
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())
})
}
+28
View File
@@ -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
+39
View File
@@ -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{}
@@ -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
@@ -2165,6 +2165,18 @@ export type GetGraphData = {
* Limit
*/
limit?: number;
/**
* Q
*/
q?: string | null;
/**
* Tags
*/
tags?: Array<string> | null;
/**
* Tags Match
*/
tags_match?: string;
};
url: "/v1/default/banks/{bank_id}/graph";
};
@@ -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,
},
});
@@ -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<any>(null);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [tagFilters, setTagFilters] = useState<string[]>([]);
const [tagInput, setTagInput] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [selectedGraphNode, setSelectedGraphNode] = useState<any>(null);
const [modalMemoryId, setModalMemoryId] = useState<string | null>(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<ReturnType<typeof setTimeout> | 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) {
</div>
) : data ? (
<>
{/* Always visible filter */}
<div className="mb-4">
<Input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Filter memories by text..."
className="max-w-md"
/>
{/* Always visible filters */}
<div className="mb-4 space-y-2">
<div className="flex items-center gap-2">
{/* Text search */}
<div className="relative max-w-xs flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Filter by text or context..."
className="pl-8 h-9"
/>
</div>
{/* Tag input */}
<div className="relative max-w-xs flex-1">
<Tag className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
type="text"
value={tagInput}
onChange={(e) => 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"
/>
</div>
</div>
{/* Active tag chips */}
{tagFilters.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{tagFilters.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-primary/10 text-primary border border-primary/20 font-medium leading-none"
>
<span className="opacity-50 select-none font-mono">#</span>
{tag}
<button
onClick={() => removeTagFilter(tag)}
className="opacity-50 hover:opacity-100 transition-opacity ml-0.5"
aria-label={`Remove tag ${tag}`}
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
</div>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<div className="text-sm text-muted-foreground">
{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 ? (
<span>
Showing {data.table_rows?.length ?? 0} of {data.total_units} total memories
@@ -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 && (
<div>
<div className="text-xs font-bold text-muted-foreground uppercase mb-2 flex items-center gap-1">
<Tag className="w-3 h-3" />
Tags
</div>
<div className="flex flex-wrap gap-1.5">
{memory.tags.map((tag, idx) => (
<span
key={idx}
className="px-2 py-0.5 bg-amber-500/10 text-amber-600 dark:text-amber-400 rounded text-xs"
>
{tag}
</span>
))}
</div>
</div>
)}
<TagList tags={memory.tags} showLabel />
{/* 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 && (
<div>
<div className="text-xs font-bold text-muted-foreground uppercase mb-2 flex items-center gap-1">
<Tag className="w-3 h-3" />
Tags
</div>
<div className="flex flex-wrap gap-1.5">
{memory.tags.map((tag, idx) => (
<span
key={idx}
className="px-2 py-0.5 bg-amber-500/10 text-amber-600 dark:text-amber-400 rounded text-xs"
>
{tag}
</span>
))}
</div>
</div>
)}
<TagList tags={memory.tags} showLabel />
{/* ID */}
<div>
@@ -544,6 +511,12 @@ export function MemoryDetailModal({ memoryId, onClose }: MemoryDetailModalProps)
</>
)}
{document.tags && document.tags.length > 0 && (
<div className="p-3 bg-muted rounded-lg">
<TagList tags={document.tags} showLabel />
</div>
)}
<div className="p-3 bg-muted rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">
Document ID
@@ -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 && (
<div>
<div className="text-xs font-bold text-muted-foreground uppercase mb-3">Tags</div>
<div className="flex flex-wrap gap-2">
{displayMemory.tags.map((tag: string, i: number) => (
<span
key={i}
className="text-sm px-3 py-1.5 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400 font-medium"
>
{tag}
</span>
))}
</div>
</div>
)}
<TagList tags={displayMemory.tags} size="md" showLabel />
{/* Source Memories (for observations) */}
{displayMemory.source_memories && displayMemory.source_memories.length > 0 && (
@@ -472,16 +459,7 @@ export function MemoryDetailPanel({
<div className={`${labelSize} font-bold text-muted-foreground uppercase mb-2`}>
Tags
</div>
<div className="flex flex-wrap gap-1">
{displayMemory.tags.map((tag: string, i: number) => (
<span
key={i}
className={`${compact ? "text-[10px] px-1.5 py-0.5" : "text-xs px-2 py-1"} rounded bg-amber-500/10 text-amber-600 dark:text-amber-400`}
>
{tag}
</span>
))}
</div>
<TagList tags={displayMemory.tags} size={compact ? "xs" : "sm"} />
</div>
)}
@@ -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 (
<div>
{showLabel && (
<div className="text-xs font-bold text-muted-foreground uppercase mb-2 flex items-center gap-1">
<Tag className="w-3 h-3" />
Tags
</div>
)}
<div className="flex flex-wrap gap-1.5">
{tags.map((tag, idx) => (
<span
key={idx}
className={`inline-flex items-center ${chipClass} bg-primary/10 text-primary border border-primary/20 leading-none`}
>
<span className="opacity-50 select-none font-mono">#</span>
{tag}
</span>
))}
</div>
</div>
);
}
+11 -1
View File
@@ -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}`);
}
@@ -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 |
+50 -19
View File
@@ -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 |
<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={recallPy} section="recall-with-tags" language="python" />
</TabItem>
</Tabs>
#### Scenario setup
<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={recallPy} section="recall-tags-strict" language="python" />
</TabItem>
</Tabs>
Consider a bank with these four memories:
<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={recallPy} section="recall-tags-all" language="python" />
</TabItem>
</Tabs>
| 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.
<CodeSnippet code={recallPy} section="recall-tags-any" language="python" />
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.
<CodeSnippet code={recallPy} section="recall-tags-any-strict" language="python" />
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.
<CodeSnippet code={recallPy} section="recall-tags-all-mode" language="python" />
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.
<CodeSnippet code={recallPy} section="recall-tags-all-strict" language="python" />
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
+60
View File
@@ -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)
# =============================================================================
+45
View File
@@ -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",