Skip to content

Commit 8fbe85f

Browse files
authored
feat: mental-models List view + /tags?source=mental_models (#1296)
* feat(api): list mental-model tags via /tags?source=mental_models Adds a `source` query param to GET /v1/default/banks/{bank_id}/tags so the same endpoint can list tags from either memory_units (default) or mental_models. Mental-model tag suggestions previously had no API; the alternative of a sibling /mental-models/tags route would have shadowed GET /mental-models/{mental_model_id} for the literal id "tags". Engine: new list_mental_model_tags method sharing a private _list_tags_from_table helper with the existing list_tags. Tests: covers the engine method (basic counts, wildcard) and an HTTP-level check that source=mental_models reads from mental_models while default remains memory_units. * feat(control-plane): mental-models List view with tag filter Adds a default split-pane "List" view to the Mental Models page (sidebar of files + content on the right) and a reusable <TagFilterInput> with free-text entry, debounced suggestions from the server, and chip selection. Changes: - Default Mental Models view is "List" (file/folder metaphor); the existing card "Dashboard" view stays as a secondary toggle. Old "Table" view removed. - Sidebar entries show name, source query subtitle, and relative refresh time. - Tag filtering is server-side via the existing tags/tags_match params on /mental-models; suggestions populate from /tags?source=mental_models. - Memories (data-view) reuse the same TagFilterInput, gaining suggestions it didn't have before. - Adds proxy route for GET /tags (forwards optional source query param). - TagFilterInput holds the caller's fetchSuggestions in a ref to keep the debounce effect from refiring on every render when callers pass an inline closure (which would otherwise loop).
1 parent e97a5c9 commit 8fbe85f

19 files changed

Lines changed: 778 additions & 179 deletions

File tree

hindsight-api-slim/hindsight_api/api/http.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4323,7 +4323,8 @@ async def api_get_document(
43234323
response_model=ListTagsResponse,
43244324
summary="List tags",
43254325
description="List all unique tags in a memory bank with usage counts. "
4326-
"Supports wildcard search using '*' (e.g., 'user:*', '*-fred', 'tag*-2'). Case-insensitive.",
4326+
"Supports wildcard search using '*' (e.g., 'user:*', '*-fred', 'tag*-2'). Case-insensitive. "
4327+
"Use `source=mental_models` to list tags used on mental models instead of memories.",
43274328
operation_id="list_tags",
43284329
tags=["Memory"],
43294330
)
@@ -4334,6 +4335,10 @@ async def api_list_tags(
43344335
description="Wildcard pattern to filter tags (e.g., 'user:*' for user:alice, '*-admin' for role-admin). "
43354336
"Use '*' as wildcard. Case-insensitive.",
43364337
),
4338+
source: Literal["memories", "mental_models"] = Query(
4339+
default="memories",
4340+
description="Where to read tags from: 'memories' (memory_units, default) or 'mental_models'.",
4341+
),
43374342
limit: int = Query(default=100, description="Maximum number of tags to return"),
43384343
offset: int = Query(default=0, description="Offset for pagination"),
43394344
request_context: RequestContext = Depends(get_request_context),
@@ -4350,17 +4355,27 @@ async def api_list_tags(
43504355
Args:
43514356
bank_id: Memory Bank ID (from path)
43524357
q: Wildcard pattern to filter tags (use '*' as wildcard)
4358+
source: Tag source — 'memories' (memory_units, default) or 'mental_models'
43534359
limit: Maximum number of tags to return (default: 100)
43544360
offset: Offset for pagination (default: 0)
43554361
"""
43564362
try:
4357-
data = await app.state.memory.list_tags(
4358-
bank_id=bank_id,
4359-
pattern=q,
4360-
limit=limit,
4361-
offset=offset,
4362-
request_context=request_context,
4363-
)
4363+
if source == "mental_models":
4364+
data = await app.state.memory.list_mental_model_tags(
4365+
bank_id=bank_id,
4366+
pattern=q,
4367+
limit=limit,
4368+
offset=offset,
4369+
request_context=request_context,
4370+
)
4371+
else:
4372+
data = await app.state.memory.list_tags(
4373+
bank_id=bank_id,
4374+
pattern=q,
4375+
limit=limit,
4376+
offset=offset,
4377+
request_context=request_context,
4378+
)
43644379
return data
43654380
except OperationValidationError as e:
43664381
raise HTTPException(status_code=e.status_code, detail=e.reason)

hindsight-api-slim/hindsight_api/engine/memory_engine.py

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6427,38 +6427,85 @@ async def list_tags(
64276427

64286428
ctx = BankReadContext(bank_id=bank_id, operation="list_tags", request_context=request_context)
64296429
await self._validate_operation(self._operation_validator.validate_bank_read(ctx))
6430+
return await self._list_tags_from_table(
6431+
table="memory_units",
6432+
bank_id=bank_id,
6433+
pattern=pattern,
6434+
limit=limit,
6435+
offset=offset,
6436+
)
6437+
6438+
async def list_mental_model_tags(
6439+
self,
6440+
bank_id: str,
6441+
*,
6442+
pattern: str | None = None,
6443+
limit: int = 100,
6444+
offset: int = 0,
6445+
request_context: "RequestContext",
6446+
) -> dict[str, Any]:
6447+
"""
6448+
List all unique tags used on mental models in a bank with usage counts.
6449+
6450+
Same wildcard semantics as list_tags. Useful to populate tag autocompletion
6451+
for UIs filtering mental models by tag.
6452+
"""
6453+
await self._authenticate_tenant(request_context)
6454+
if self._operation_validator:
6455+
from hindsight_api.extensions import BankReadContext
6456+
6457+
ctx = BankReadContext(
6458+
bank_id=bank_id,
6459+
operation="list_mental_model_tags",
6460+
request_context=request_context,
6461+
)
6462+
await self._validate_operation(self._operation_validator.validate_bank_read(ctx))
6463+
return await self._list_tags_from_table(
6464+
table="mental_models",
6465+
bank_id=bank_id,
6466+
pattern=pattern,
6467+
limit=limit,
6468+
offset=offset,
6469+
)
6470+
6471+
async def _list_tags_from_table(
6472+
self,
6473+
*,
6474+
table: str,
6475+
bank_id: str,
6476+
pattern: str | None,
6477+
limit: int,
6478+
offset: int,
6479+
) -> dict[str, Any]:
64306480
pool = await self._get_pool()
64316481
async with acquire_with_retry(pool) as conn:
64326482
# Build pattern filter if provided (convert * to % for ILIKE)
64336483
pattern_clause = ""
64346484
params: list[Any] = [bank_id]
64356485
if pattern:
6436-
# Convert wildcard pattern: * -> % for SQL ILIKE
64376486
sql_pattern = pattern.replace("*", "%")
64386487
pattern_clause = "AND tag ILIKE $2"
64396488
params.append(sql_pattern)
64406489

6441-
# Get total count of distinct tags matching pattern
64426490
total_row = await conn.fetchrow(
64436491
f"""
64446492
SELECT COUNT(DISTINCT tag) as total
6445-
FROM {fq_table("memory_units")}, unnest(tags) AS tag
6493+
FROM {fq_table(table)}, unnest(tags) AS tag
64466494
WHERE bank_id = $1 AND tags IS NOT NULL AND tags != '{{}}'
64476495
{pattern_clause}
64486496
""",
64496497
*params,
64506498
)
64516499
total = total_row["total"] if total_row else 0
64526500

6453-
# Get paginated tags with counts, ordered by frequency
64546501
limit_param = len(params) + 1
64556502
offset_param = len(params) + 2
64566503
params.extend([limit, offset])
64576504

64586505
rows = await conn.fetch(
64596506
f"""
64606507
SELECT tag, COUNT(*) as count
6461-
FROM {fq_table("memory_units")}, unnest(tags) AS tag
6508+
FROM {fq_table(table)}, unnest(tags) AS tag
64626509
WHERE bank_id = $1 AND tags IS NOT NULL AND tags != '{{}}'
64636510
{pattern_clause}
64646511
GROUP BY tag

hindsight-api-slim/tests/test_tags_visibility.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1501,3 +1501,98 @@ async def test_tag_groups_nested_and_containing_or(api_client):
15011501
assert any("Alice" in t and "step 8" in t for t in texts), "Should find Alice step:8"
15021502
assert not any("step 9" in t for t in texts), "Should NOT find step 9"
15031503
assert not any("Bob" in t for t in texts), "Should NOT find Bob"
1504+
1505+
1506+
# ============================================================================
1507+
# Tests for list_mental_model_tags API endpoint
1508+
# ============================================================================
1509+
1510+
1511+
async def _create_mental_model_via_engine(memory, *, bank_id, name, tags, request_context):
1512+
"""Helper that creates a mental model directly through the engine without an LLM call."""
1513+
# Ensure the bank exists (mental_models has a FK to banks).
1514+
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
1515+
return await memory.create_mental_model(
1516+
bank_id=bank_id,
1517+
name=name,
1518+
source_query=f"Source query for {name}",
1519+
content=f"Content for {name}",
1520+
tags=tags,
1521+
request_context=request_context,
1522+
)
1523+
1524+
1525+
@pytest.mark.asyncio
1526+
async def test_list_mental_model_tags_returns_only_mental_model_tags(memory, request_context):
1527+
"""Mental-model tag listing should reflect only mental_models.tags, not memory_units.tags."""
1528+
bank_id = f"mm_tags_basic_{datetime.now().timestamp()}"
1529+
1530+
await _create_mental_model_via_engine(
1531+
memory, bank_id=bank_id, name="MM A", tags=["topic:alpha", "shared"], request_context=request_context
1532+
)
1533+
await _create_mental_model_via_engine(
1534+
memory, bank_id=bank_id, name="MM B", tags=["topic:beta", "shared"], request_context=request_context
1535+
)
1536+
await _create_mental_model_via_engine(
1537+
memory, bank_id=bank_id, name="MM C", tags=["topic:alpha"], request_context=request_context
1538+
)
1539+
1540+
result = await memory.list_mental_model_tags(bank_id=bank_id, request_context=request_context)
1541+
1542+
tags_map = {item["tag"]: item["count"] for item in result["items"]}
1543+
assert tags_map == {"topic:alpha": 2, "topic:beta": 1, "shared": 2}
1544+
assert result["total"] == 3
1545+
1546+
# Sanity check: the regular list_tags (which queries memory_units) should not see these tags
1547+
# since no memories exist in this bank.
1548+
memory_tags = await memory.list_tags(bank_id=bank_id, request_context=request_context)
1549+
assert memory_tags["items"] == []
1550+
1551+
1552+
@pytest.mark.asyncio
1553+
async def test_list_mental_model_tags_with_wildcard(memory, request_context):
1554+
"""Wildcard 'topic:*' should only match mental-model tags with that prefix."""
1555+
bank_id = f"mm_tags_wildcard_{datetime.now().timestamp()}"
1556+
1557+
await _create_mental_model_via_engine(
1558+
memory, bank_id=bank_id, name="MM 1", tags=["topic:alpha", "user:alice"], request_context=request_context
1559+
)
1560+
await _create_mental_model_via_engine(
1561+
memory, bank_id=bank_id, name="MM 2", tags=["topic:beta"], request_context=request_context
1562+
)
1563+
await _create_mental_model_via_engine(
1564+
memory, bank_id=bank_id, name="MM 3", tags=["session:abc"], request_context=request_context
1565+
)
1566+
1567+
result = await memory.list_mental_model_tags(
1568+
bank_id=bank_id, pattern="topic:*", request_context=request_context
1569+
)
1570+
1571+
returned = sorted(item["tag"] for item in result["items"])
1572+
assert returned == ["topic:alpha", "topic:beta"]
1573+
assert result["total"] == 2
1574+
1575+
1576+
@pytest.mark.asyncio
1577+
async def test_list_tags_endpoint_with_source_mental_models(memory, request_context):
1578+
"""`/tags?source=mental_models` returns mental-model tags, not memory_units tags."""
1579+
bank_id = f"mm_tags_source_{datetime.now().timestamp()}"
1580+
1581+
await _create_mental_model_via_engine(
1582+
memory, bank_id=bank_id, name="MM 1", tags=["alpha"], request_context=request_context
1583+
)
1584+
1585+
app = create_app(memory, initialize_memory=False)
1586+
transport = httpx.ASGITransport(app=app)
1587+
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
1588+
response = await client.get(
1589+
f"/v1/default/banks/{bank_id}/tags", params={"source": "mental_models"}
1590+
)
1591+
assert response.status_code == 200, response.text
1592+
body = response.json()
1593+
assert {item["tag"] for item in body["items"]} == {"alpha"}
1594+
1595+
# Default source ('memories') must NOT pick up the mental-model tag.
1596+
default_response = await client.get(f"/v1/default/banks/{bank_id}/tags")
1597+
assert default_response.status_code == 200, default_response.text
1598+
assert default_response.json()["items"] == []

hindsight-cli/src/api.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -712,7 +712,7 @@ impl ApiClient {
712712
self.runtime.block_on(async {
713713
let response = self
714714
.client
715-
.list_tags(bank_id, limit, offset, q, None)
715+
.list_tags(bank_id, limit, offset, q, None, None)
716716
.await?;
717717
Ok(response.into_inner())
718718
})

hindsight-clients/go/api/openapi.yaml

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1776,7 +1776,9 @@ paths:
17761776
/v1/default/banks/{bank_id}/tags:
17771777
get:
17781778
description: "List all unique tags in a memory bank with usage counts. Supports\
1779-
\ wildcard search using '*' (e.g., 'user:*', '*-fred', 'tag*-2'). Case-insensitive."
1779+
\ wildcard search using '*' (e.g., 'user:*', '*-fred', 'tag*-2'). Case-insensitive.\
1780+
\ Use `source=mental_models` to list tags used on mental models instead of\
1781+
\ memories."
17801782
operationId: list_tags
17811783
parameters:
17821784
- explode: false
@@ -1797,6 +1799,22 @@ paths:
17971799
nullable: true
17981800
type: string
17991801
style: form
1802+
- description: "Where to read tags from: 'memories' (memory_units, default)\
1803+
\ or 'mental_models'."
1804+
explode: true
1805+
in: query
1806+
name: source
1807+
required: false
1808+
schema:
1809+
default: memories
1810+
description: "Where to read tags from: 'memories' (memory_units, default)\
1811+
\ or 'mental_models'."
1812+
enum:
1813+
- memories
1814+
- mental_models
1815+
title: Source
1816+
type: string
1817+
style: form
18001818
- description: Maximum number of tags to return
18011819
explode: true
18021820
in: query
@@ -2127,8 +2145,8 @@ paths:
21272145
/v1/default/banks/{bank_id}/profile:
21282146
get:
21292147
deprecated: true
2130-
description: Get disposition traits and mission for a memory bank. Auto-creates
2131-
agent with defaults if not exists.
2148+
description: Get disposition traits and mission for a memory bank. Returns 404
2149+
if the bank does not exist.
21322150
operationId: get_bank_profile
21332151
parameters:
21342152
- explode: false

hindsight-clients/go/api_banks.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

hindsight-clients/go/api_memory.go

Lines changed: 14 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

hindsight-clients/python/hindsight_client_api/api/banks_api.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1797,7 +1797,7 @@ async def get_bank_profile(
17971797
) -> BankProfileResponse:
17981798
"""(Deprecated) Get memory bank profile
17991799
1800-
Get disposition traits and mission for a memory bank. Auto-creates agent with defaults if not exists.
1800+
Get disposition traits and mission for a memory bank. Returns 404 if the bank does not exist.
18011801
18021802
:param bank_id: (required)
18031803
:type bank_id: str
@@ -1870,7 +1870,7 @@ async def get_bank_profile_with_http_info(
18701870
) -> ApiResponse[BankProfileResponse]:
18711871
"""(Deprecated) Get memory bank profile
18721872
1873-
Get disposition traits and mission for a memory bank. Auto-creates agent with defaults if not exists.
1873+
Get disposition traits and mission for a memory bank. Returns 404 if the bank does not exist.
18741874
18751875
:param bank_id: (required)
18761876
:type bank_id: str
@@ -1943,7 +1943,7 @@ async def get_bank_profile_without_preload_content(
19431943
) -> RESTResponseType:
19441944
"""(Deprecated) Get memory bank profile
19451945
1946-
Get disposition traits and mission for a memory bank. Auto-creates agent with defaults if not exists.
1946+
Get disposition traits and mission for a memory bank. Returns 404 if the bank does not exist.
19471947
19481948
:param bank_id: (required)
19491949
:type bank_id: str

0 commit comments

Comments
 (0)