Skip to content

Commit 6413343

Browse files
fix(api): support delete_memory by user_id/conversation_id (Fixes #1103) (#1104)
* fix(api): support delete_memory by user_id and conversation_id * fix(api): harden delete filter validation and tests * fix(postgres): implement delete_node_by_prams for filter deletes * reformat Remove unnecessary blank line in product_models.py * Update memory_handler.py --------- Co-authored-by: CaralHsi <caralhsi@gmail.com>
1 parent 79e216b commit 6413343

5 files changed

Lines changed: 470 additions & 19 deletions

File tree

src/memos/api/handlers/memory_handler.py

Lines changed: 85 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -314,26 +314,99 @@ def handle_get_memories(
314314
return GetMemoryResponse(message="Memories retrieved successfully", data=filtered_results)
315315

316316

317+
def _build_quick_delete_constraints(delete_mem_req: DeleteMemoryRequest) -> dict[str, Any]:
318+
"""Build fast-delete constraints from request-level fields."""
319+
constraints: dict[str, Any] = {}
320+
if delete_mem_req.user_id is not None:
321+
constraints["user_id"] = delete_mem_req.user_id
322+
if delete_mem_req.session_id is not None:
323+
constraints["session_id"] = delete_mem_req.session_id
324+
return constraints
325+
326+
327+
def _merge_delete_filter(
328+
base_filter: dict[str, Any] | None,
329+
constraints: dict[str, Any],
330+
) -> dict[str, Any]:
331+
"""Merge user/session constraints into an existing filter."""
332+
if not constraints:
333+
return base_filter or {}
334+
if base_filter is None:
335+
return {"and": [constraints.copy()]}
336+
337+
if not base_filter:
338+
return {"and": [constraints.copy()]}
339+
340+
if "and" in base_filter:
341+
and_conditions = base_filter.get("and")
342+
if not isinstance(and_conditions, list):
343+
raise ValueError("Invalid filter format: 'and' must be a list")
344+
return {"and": [*and_conditions, constraints.copy()]}
345+
346+
if "or" in base_filter:
347+
or_conditions = base_filter.get("or")
348+
if not isinstance(or_conditions, list):
349+
raise ValueError("Invalid filter format: 'or' must be a list")
350+
351+
merged_or_conditions: list[dict[str, Any]] = []
352+
for condition in or_conditions:
353+
if not isinstance(condition, dict):
354+
raise ValueError("Invalid filter format: each 'or' condition must be a dict")
355+
merged_condition = condition.copy()
356+
for key, value in constraints.items():
357+
if key in merged_condition and merged_condition[key] != value:
358+
raise ValueError(
359+
f"Conflicting filter condition for '{key}'. "
360+
"Please merge it manually into request.filter."
361+
)
362+
merged_condition[key] = value
363+
merged_or_conditions.append(merged_condition)
364+
365+
return {"or": merged_or_conditions}
366+
367+
# For plain dict filters, keep strict AND semantics explicitly.
368+
return {"and": [base_filter.copy(), constraints.copy()]}
369+
370+
317371
def handle_delete_memories(delete_mem_req: DeleteMemoryRequest, naive_mem_cube: NaiveMemCube):
318372
"""
319373
Handler for deleting memories.
320374
Now unified to delete from text_mem only (includes preferences).
321375
"""
322376
logger.info(
323-
"[Delete memory request] writable_cube_ids: %s, memory_ids: %s, auto_cleanup_working: %s",
377+
"[Delete memory request] writable_cube_ids: %s, memory_ids: %s, file_ids: %s, auto_cleanup_working: %s"
378+
"has_filter: %s, user_id: %s, session_id: %s",
324379
delete_mem_req.writable_cube_ids,
325380
delete_mem_req.memory_ids,
381+
delete_mem_req.file_ids,
382+
delete_mem_req.filter is not None,
383+
delete_mem_req.user_id,
384+
delete_mem_req.session_id,
326385
getattr(delete_mem_req, "auto_cleanup_working", False),
327386
)
328-
# Validate that only one of memory_ids, file_ids, or filter is provided
387+
quick_constraints = _build_quick_delete_constraints(delete_mem_req)
388+
has_non_empty_filter = bool(delete_mem_req.filter)
389+
has_filter_mode = has_non_empty_filter or bool(quick_constraints)
390+
391+
# Reject empty filter dict when no quick constraints are provided.
392+
if delete_mem_req.filter is not None and not has_non_empty_filter and not quick_constraints:
393+
return DeleteMemoryResponse(
394+
message="filter cannot be empty. Provide a non-empty filter or user_id/session_id.",
395+
data={"status": "failure"},
396+
)
397+
398+
# Validate that only one mode is provided: memory_ids, file_ids, or filter-mode.
329399
provided_params = [
330400
delete_mem_req.memory_ids is not None,
331401
delete_mem_req.file_ids is not None,
332-
delete_mem_req.filter is not None,
402+
has_filter_mode,
333403
]
334404
if sum(provided_params) != 1:
335405
return DeleteMemoryResponse(
336-
message="Exactly one of memory_ids, file_ids, or filter must be provided",
406+
message=(
407+
"Exactly one delete mode must be provided: "
408+
"memory_ids, file_ids, or filter/user_id/session_id."
409+
),
337410
data={"status": "failure"},
338411
)
339412

@@ -370,8 +443,14 @@ def handle_delete_memories(delete_mem_req: DeleteMemoryRequest, naive_mem_cube:
370443
naive_mem_cube.text_mem.delete_by_filter(
371444
writable_cube_ids=delete_mem_req.writable_cube_ids, file_ids=delete_mem_req.file_ids
372445
)
373-
elif delete_mem_req.filter is not None:
374-
naive_mem_cube.text_mem.delete_by_filter(filter=delete_mem_req.filter)
446+
elif has_filter_mode:
447+
merged_filter = _merge_delete_filter(delete_mem_req.filter, quick_constraints)
448+
naive_mem_cube.text_mem.delete_by_filter(
449+
writable_cube_ids=delete_mem_req.writable_cube_ids,
450+
filter=merged_filter,
451+
)
452+
if naive_mem_cube.pref_mem is not None:
453+
naive_mem_cube.pref_mem.delete_by_filter(filter=merged_filter)
375454

376455
# After main deletion, optionally clean up related WorkingMemory nodes.
377456
if working_ids_to_delete:

src/memos/api/product_models.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -854,10 +854,22 @@ class GetMemoryDashboardRequest(GetMemoryRequest):
854854
class DeleteMemoryRequest(BaseRequest):
855855
"""Request model for deleting memories."""
856856

857-
writable_cube_ids: list[str] = Field(None, description="Writable cube IDs")
857+
writable_cube_ids: list[str] | None = Field(None, description="Writable cube IDs")
858858
memory_ids: list[str] | None = Field(None, description="Memory IDs")
859859
file_ids: list[str] | None = Field(None, description="File IDs")
860860
filter: dict[str, Any] | None = Field(None, description="Filter for the memory")
861+
user_id: str | None = Field(
862+
None,
863+
description="Quick delete condition: remove memories for this user_id.",
864+
)
865+
session_id: str | None = Field(
866+
None,
867+
description="Quick delete condition: remove memories for this session_id.",
868+
)
869+
conversation_id: str | None = Field(
870+
None,
871+
description="Alias of session_id for backward compatibility.",
872+
)
861873
auto_cleanup_working: bool | None = Field(
862874
False,
863875
description=(
@@ -866,6 +878,15 @@ class DeleteMemoryRequest(BaseRequest):
866878
),
867879
)
868880

881+
@model_validator(mode="after")
882+
def normalize_session_alias(self) -> "DeleteMemoryRequest":
883+
"""Normalize conversation_id to session_id."""
884+
if self.conversation_id and self.session_id and self.conversation_id != self.session_id:
885+
raise ValueError("conversation_id and session_id must be the same when both are set")
886+
if self.session_id is None and self.conversation_id is not None:
887+
self.session_id = self.conversation_id
888+
return self
889+
869890

870891
class SuggestionRequest(BaseRequest):
871892
"""Request model for getting suggestion queries."""

src/memos/graph_dbs/neo4j_community.py

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -889,6 +889,14 @@ def build_filter_condition(
889889
if condition_str:
890890
where_clauses.append(f"({condition_str})")
891891
filter_params.update(filter_params_inner)
892+
else:
893+
# Simple dict syntax: {"user_id": "...", "session_id": "..."}
894+
condition_str, filter_params_inner = build_filter_condition(
895+
filter, param_counter
896+
)
897+
if condition_str:
898+
where_clauses.append(f"({condition_str})")
899+
filter_params.update(filter_params_inner)
892900

893901
where_str = " AND ".join(where_clauses) if where_clauses else ""
894902
if where_str:
@@ -919,7 +927,7 @@ def build_filter_condition(
919927

920928
def delete_node_by_prams(
921929
self,
922-
writable_cube_ids: list[str],
930+
writable_cube_ids: list[str] | None = None,
923931
memory_ids: list[str] | None = None,
924932
file_ids: list[str] | None = None,
925933
filter: dict | None = None,
@@ -928,7 +936,7 @@ def delete_node_by_prams(
928936
Delete nodes by memory_ids, file_ids, or filter.
929937
930938
Args:
931-
writable_cube_ids (list[str]): List of cube IDs (user_name) to filter nodes. Required parameter.
939+
writable_cube_ids (list[str], optional): List of cube IDs (user_name) to scope deletion.
932940
memory_ids (list[str], optional): List of memory node IDs to delete.
933941
file_ids (list[str], optional): List of file node IDs to delete.
934942
filter (dict, optional): Filter dictionary to query matching nodes for deletion.
@@ -943,20 +951,21 @@ def delete_node_by_prams(
943951
f"[delete_node_by_prams] memory_ids: {memory_ids}, file_ids: {file_ids}, filter: {filter}, writable_cube_ids: {writable_cube_ids}"
944952
)
945953

946-
# Validate writable_cube_ids
947-
if not writable_cube_ids or len(writable_cube_ids) == 0:
948-
raise ValueError("writable_cube_ids is required and cannot be empty")
954+
# file_ids deletion must be scoped by writable_cube_ids.
955+
if file_ids and (not writable_cube_ids or len(writable_cube_ids) == 0):
956+
raise ValueError("writable_cube_ids is required when deleting by file_ids")
949957

950958
# Build WHERE conditions separately for memory_ids and file_ids
951959
where_clauses = []
952960
params = {}
953961

954962
# Build user_name condition from writable_cube_ids (OR relationship - match any cube_id)
955963
user_name_conditions = []
956-
for idx, cube_id in enumerate(writable_cube_ids):
957-
param_name = f"cube_id_{idx}"
958-
user_name_conditions.append(f"n.user_name = ${param_name}")
959-
params[param_name] = cube_id
964+
if writable_cube_ids:
965+
for idx, cube_id in enumerate(writable_cube_ids):
966+
param_name = f"cube_id_{idx}"
967+
user_name_conditions.append(f"n.user_name = ${param_name}")
968+
params[param_name] = cube_id
960969

961970
# Handle memory_ids: query n.id
962971
if memory_ids and len(memory_ids) > 0:
@@ -1003,9 +1012,12 @@ def delete_node_by_prams(
10031012
# First, combine memory_ids, file_ids, and filter conditions with OR (any condition can match)
10041013
data_conditions = " OR ".join([f"({clause})" for clause in where_clauses])
10051014

1006-
# Then, combine with user_name condition using AND (must match user_name AND one of the data conditions)
1007-
user_name_where = " OR ".join(user_name_conditions)
1008-
ids_where = f"({user_name_where}) AND ({data_conditions})"
1015+
# Then, combine with user_name condition using AND when scope is provided.
1016+
if user_name_conditions:
1017+
user_name_where = " OR ".join(user_name_conditions)
1018+
ids_where = f"({user_name_where}) AND ({data_conditions})"
1019+
else:
1020+
ids_where = data_conditions
10091021

10101022
logger.info(
10111023
f"[delete_node_by_prams] Deleting nodes - memory_ids: {memory_ids}, file_ids: {file_ids}, filter: {filter}"

0 commit comments

Comments
 (0)