Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf0810b390 | ||
|
|
3fbb71ae55 | ||
|
|
7d7f7bd86d |
@@ -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 [],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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']}"
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
# =============================================================================
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user