diff --git a/docs/site/api-reference/events/search_events_by_tags.mdx b/docs/site/api-reference/events/search_events_by_tags.mdx new file mode 100644 index 0000000..a21bff9 --- /dev/null +++ b/docs/site/api-reference/events/search_events_by_tags.mdx @@ -0,0 +1,6 @@ +--- +title: 'Search Events By Tags' +openapi: get /api/v1/users/event_tags/search/{user_id} +--- +Search events by tags. + diff --git a/docs/site/docs.json b/docs/site/docs.json index 3cb8c83..850f360 100644 --- a/docs/site/docs.json +++ b/docs/site/docs.json @@ -150,6 +150,7 @@ "api-reference/events/get_events", "api-reference/events/search_events", "api-reference/events/search_event_gists", + "api-reference/events/search_events_by_tags", "api-reference/events/update_event", "api-reference/events/delete_event" ] diff --git a/docs/site/features/event/event_tag.mdx b/docs/site/features/event/event_tag.mdx index a36e47e..b110bac 100644 --- a/docs/site/features/event/event_tag.mdx +++ b/docs/site/features/event/event_tag.mdx @@ -39,7 +39,7 @@ for event in events: ## Searching Events by Tag -You can also search for events that have specific tags applied. +You can search for events that have specific tags applied using the `search_event_by_tags` method. ```python from memobase import MemoBaseClient @@ -47,9 +47,10 @@ from memobase import MemoBaseClient client = MemoBaseClient(project_url='YOUR_PROJECT_URL', api_key='YOUR_API_KEY') user = client.get_user('some_user_id') -# Find all events tagged with 'emotion' -events = user.search_event(tags=["emotion"]) +# Find events with specific tags (AND condition) +events = user.search_event_by_tags(tags=["emotion", "romance"]) print(events) ``` -For more details, see the [API Reference](/api-reference/events/search_events). \ No newline at end of file + +For more details, see the [API Reference](/api-reference/events/search_events_by_tags). \ No newline at end of file diff --git a/docs/site/openapi.json b/docs/site/openapi.json index 1cc2f3f..6da58de 100644 --- a/docs/site/openapi.json +++ b/docs/site/openapi.json @@ -2221,6 +2221,100 @@ ] } }, + "/api/v1/users/event_tags/search/{user_id}": { + "get": { + "tags": [ + "event" + ], + "summary": "Search User Events By Tags", + "operationId": "search_user_events_by_tags_api_v1_users_event_tags_search__user_id__get", + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "uuid4" + }, + { + "type": "string", + "format": "uuid5" + } + ], + "description": "The ID of the user", + "title": "User Id" + }, + "description": "The ID of the user" + }, + { + "name": "tags", + "in": "query", + "required": false, + "schema": { + "type": "string", + "description": "Comma-separated list of tag names that events must have (e.g.'emotion,romance')", + "title": "Tags" + }, + "description": "Comma-separated list of tag names that events must have (e.g.'emotion,romance')" + }, + { + "name": "tag_values", + "in": "query", + "required": false, + "schema": { + "type": "string", + "description": "Comma-separated tag=value pairs for exact matches (e.g., 'emotion=happy,topic=work')", + "title": "Tag Values" + }, + "description": "Comma-separated tag=value pairs for exact matches (e.g., 'emotion=happy,topic=work')" + }, + { + "name": "topk", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "description": "Number of events to retrieve, default is 10", + "default": 10, + "title": "Topk" + }, + "description": "Number of events to retrieve, default is 10" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserEventsDataResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "x-code-samples": [ + { + "lang": "python", + "source": "# To use the Python SDK, install the package:\n# pip install memobase\n\nfrom memobase import MemoBaseClient\n\nclient = MemoBaseClient(project_url='PROJECT_URL', api_key='PROJECT_TOKEN')\nu = client.get_user(uid)\n\n# Search for events with specific tags\nevents = u.search_event_by_tags(tags=[\"emotion\", \"romance\"])\n\n# Search for events with specific tag values\nevents = u.search_event_by_tags(tag_values={\"emotion\": \"happy\", \"topic\": \"work\"})\n\n# Combine both filters\nevents = u.search_event_by_tags(tags=[\"emotion\"], tag_values={\"topic\": \"work\"})\n\n", + "label": "Python" + } + ] + } + }, "/api/v1/users/context/{user_id}": { "get": { "tags": [ diff --git a/src/client/memobase/core/entry.py b/src/client/memobase/core/entry.py index e26498e..b0d9803 100644 --- a/src/client/memobase/core/entry.py +++ b/src/client/memobase/core/entry.py @@ -311,6 +311,47 @@ def search_event_gist( ) return [UserEventGistData.model_validate(e) for e in r.data["gists"]] + def search_event_by_tags( + self, + tags: Optional[list[str]] = None, + tag_values: Optional[dict[str, str]] = None, + topk: int = 10, + ) -> list[UserEventData]: + """ + Search user events by tags. + + Args: + tags: List of tag names that events must have (AND condition) + tag_values: Dict of tag=value pairs for exact matches (AND condition) + topk: Number of events to retrieve, default is 10 + + Examples: + - search_event_by_tags(tags=["emotion", "romance"]) + Returns events that have both 'emotion' AND 'romance' tags (with any value) + + - search_event_by_tags(tag_values={"emotion": "happy", "topic": "work"}) + Returns events where emotion tag equals 'happy' AND topic tag equals 'work' + + - search_event_by_tags(tags=["emotion"], tag_values={"topic": "work"}) + Returns events that have 'emotion' tag (any value) AND topic tag equals 'work' + """ + params = f"?topk={topk}" + + if tags: + tags_str = ",".join(tags) + params += f"&tags={tags_str}" + + if tag_values: + tag_values_str = ",".join([f"{k}={v}" for k, v in tag_values.items()]) + params += f"&tag_values={tag_values_str}" + + r = unpack_response( + self.project_client.client.get( + f"/users/event_tags/search/{self.user_id}{params}" + ) + ) + return [UserEventData.model_validate(e) for e in r.data["events"]] + def context( self, max_token_size: int = 1000, diff --git a/src/server/api/api.py b/src/server/api/api.py index 2525a6b..bc9aad6 100644 --- a/src/server/api/api.py +++ b/src/server/api/api.py @@ -258,6 +258,12 @@ def custom_openapi(): openapi_extra=API_X_CODE_DOCS["GET /users/event_gist/search/{user_id}"], )(api_layer.event.search_user_event_gists) +router.get( + "/users/event_tags/search/{user_id}", + tags=["event"], + openapi_extra=API_X_CODE_DOCS["GET /users/event_tags/search/{user_id}"], +)(api_layer.event.search_user_events_by_tags) + router.get( "/users/context/{user_id}", tags=["context"], diff --git a/src/server/api/memobase_server/api_layer/docs/event.py b/src/server/api/memobase_server/api_layer/docs/event.py index e9b5823..4c28d28 100644 --- a/src/server/api/memobase_server/api_layer/docs/event.py +++ b/src/server/api/memobase_server/api_layer/docs/event.py @@ -258,6 +258,29 @@ ) +# Search user events by tags +add_api_code_docs( + "GET", + "/users/event_tags/search/{user_id}", + py_code( + """ +from memobase import MemoBaseClient + +client = MemoBaseClient(project_url='PROJECT_URL', api_key='PROJECT_TOKEN') +u = client.get_user(uid) + +# Search for events with specific tags +events = u.search_event_by_tags(tags=["emotion", "romance"]) + +# Search for events with specific tag values +events = u.search_event_by_tags(tag_values={"emotion": "happy", "topic": "work"}) + +# Combine both filters +events = u.search_event_by_tags(tags=["emotion"], tag_values={"topic": "work"}) +""" + ), +) + add_api_code_docs( "GET", "/users/event_gist/search/{user_id}", diff --git a/src/server/api/memobase_server/api_layer/event.py b/src/server/api/memobase_server/api_layer/event.py index 6d7550b..67d841d 100644 --- a/src/server/api/memobase_server/api_layer/event.py +++ b/src/server/api/memobase_server/api_layer/event.py @@ -98,3 +98,31 @@ async def search_user_event_gists( user_id, project_id, query, topk, similarity_threshold, time_range_in_days ) return p.to_response(res.UserEventGistsDataResponse) + + +async def search_user_events_by_tags( + request: Request, + user_id: UUID = Path(..., description="The ID of the user"), + tags: str = Query(None, description="Comma-separated list of tag names that events must have (e.g.'emotion,romance')"), + tag_values: str = Query(None, description="Comma-separated tag=value pairs for exact matches (e.g., 'emotion=happy,topic=work')"), + topk: int = Query(10, description="Number of events to retrieve, default is 10"), +) -> res.UserEventsDataResponse: + project_id = request.state.memobase_project_id + + has_event_tag = None + if tags: + has_event_tag = [tag.strip() for tag in tags.split(",") if tag.strip()] + + event_tag_equal = None + if tag_values: + event_tag_equal = {} + for pair in tag_values.split(","): + if "=" in pair: + tag_name, tag_value = pair.split("=", 1) + event_tag_equal[tag_name.strip()] = tag_value.strip() + + p = await controllers.event.filter_user_events( + user_id, project_id, has_event_tag, event_tag_equal, topk + ) + + return p.to_response(res.UserEventsDataResponse) diff --git a/src/server/api/tests/test_api.py b/src/server/api/tests/test_api.py index c2ea8ad..8540c01 100644 --- a/src/server/api/tests/test_api.py +++ b/src/server/api/tests/test_api.py @@ -606,6 +606,96 @@ async def test_api_event_search( assert d["errno"] == 0 +@pytest.mark.asyncio +async def test_api_event_search_by_tags( + client, + db_env, + mock_llm_complete, + mock_llm_validate_complete, + mock_event_summary_llm_complete, + mock_entry_summary_llm_complete, + mock_event_get_embedding, +): + # Create a user + response = client.post(f"{PREFIX}/users", json={}) + d = response.json() + assert response.status_code == 200 + assert d["errno"] == 0 + u_id = d["data"]["id"] + + # Insert a chat blob that will create an event with tags + response = client.post( + f"{PREFIX}/blobs/insert/{u_id}", + json={ + "blob_type": "chat", + "blob_data": { + "messages": [ + {"role": "user", "content": "I'm feeling happy today"}, + {"role": "assistant", "content": "That's wonderful!"}, + ] + }, + }, + ) + d = response.json() + assert response.status_code == 200 + assert d["errno"] == 0 + + # Process the buffer to create the event + response = client.post(f"{PREFIX}/users/buffer/{u_id}/chat") + assert response.status_code == 200 + assert response.json()["errno"] == 0 + + # Test 1: Search by single tag name + response = client.get(f"{PREFIX}/users/event_tags/search/{u_id}?tags=emotion") + d = response.json() + assert response.status_code == 200 + assert d["errno"] == 0 + assert len(d["data"]["events"]) == 1 + assert d["data"]["events"][0]["event_data"]["event_tags"][0]["tag"] == "emotion" + assert d["data"]["events"][0]["event_data"]["event_tags"][0]["value"] == "happy" + + # Test 2: Search by multiple tag names (comma-separated) + response = client.get(f"{PREFIX}/users/event_tags/search/{u_id}?tags=emotion,nonexistent") + d = response.json() + assert response.status_code == 200 + assert d["errno"] == 0 + assert len(d["data"]["events"]) == 0 # Should be empty since "nonexistent" tag doesn't exist + + # Test 3: Search by tag value + response = client.get(f"{PREFIX}/users/event_tags/search/{u_id}?tag_values=emotion=happy") + d = response.json() + assert response.status_code == 200 + assert d["errno"] == 0 + assert len(d["data"]["events"]) == 1 + + # Test 4: Search by wrong tag value + response = client.get(f"{PREFIX}/users/event_tags/search/{u_id}?tag_values=emotion=sad") + d = response.json() + assert response.status_code == 200 + assert d["errno"] == 0 + assert len(d["data"]["events"]) == 0 + + # Test 5: Search with topk parameter + response = client.get(f"{PREFIX}/users/event_tags/search/{u_id}?tags=emotion&topk=5") + d = response.json() + assert response.status_code == 200 + assert d["errno"] == 0 + assert len(d["data"]["events"]) <= 5 + + # Test 6: Search with both tags and tag_values + response = client.get(f"{PREFIX}/users/event_tags/search/{u_id}?tags=emotion&tag_values=emotion=happy") + d = response.json() + assert response.status_code == 200 + assert d["errno"] == 0 + assert len(d["data"]["events"]) == 1 + + # Clean up + response = client.delete(f"{PREFIX}/users/{u_id}") + d = response.json() + assert response.status_code == 200 + assert d["errno"] == 0 + + @pytest.mark.asyncio async def test_api_non_uuid_access(client, db_env):