mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-14 23:21:20 +00:00
Merge a2328e8ace into 9652478093
This commit is contained in:
commit
2a1d42a6ed
6 changed files with 288 additions and 28 deletions
|
|
@ -101,6 +101,12 @@ def deduplicate_memories(
|
|||
if isinstance(item, str):
|
||||
trimmed = item.strip()
|
||||
return trimmed if trimmed else None
|
||||
# SDK search results are pydantic models, not dicts — read the
|
||||
# memory field off the object so they aren't silently dropped.
|
||||
memory = getattr(item, "memory", None)
|
||||
if isinstance(memory, str):
|
||||
trimmed = memory.strip()
|
||||
return trimmed if trimmed else None
|
||||
return None
|
||||
|
||||
static_memories: list[str] = []
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
"""Tests for utility functions."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from supermemory_agent_framework.utils import (
|
||||
|
|
@ -56,6 +58,30 @@ class TestDeduplicateMemories:
|
|||
)
|
||||
assert result.static == ["valid"]
|
||||
|
||||
def test_model_object_search_results(self) -> None:
|
||||
# SDK search results are pydantic models (attribute access, no
|
||||
# .get()); they used to fall through extract_memory_text and be
|
||||
# silently dropped.
|
||||
results = [
|
||||
SimpleNamespace(memory="User prefers async"),
|
||||
SimpleNamespace(memory="User prefers async"),
|
||||
SimpleNamespace(memory=" "),
|
||||
SimpleNamespace(memory=None),
|
||||
]
|
||||
result = deduplicate_memories(search_results=results)
|
||||
assert result.search_results == ["User prefers async"]
|
||||
|
||||
def test_model_objects_deduplicate_against_profile(self) -> None:
|
||||
result = deduplicate_memories(
|
||||
static=["User likes Python"],
|
||||
search_results=[
|
||||
SimpleNamespace(memory="User likes Python"),
|
||||
SimpleNamespace(memory="User prefers async"),
|
||||
],
|
||||
)
|
||||
assert result.static == ["User likes Python"]
|
||||
assert result.search_results == ["User prefers async"]
|
||||
|
||||
|
||||
class TestConvertProfileToMarkdown:
|
||||
def test_empty_profile(self) -> None:
|
||||
|
|
|
|||
|
|
@ -49,17 +49,45 @@ def format_relative_time(iso_timestamp: str) -> str:
|
|||
return ""
|
||||
|
||||
|
||||
def extract_search_result_fields(item: Any) -> tuple[str, str]:
|
||||
"""Extract (memory, updatedAt) from a search result.
|
||||
|
||||
The Supermemory SDK returns search results as pydantic models
|
||||
(attribute access, snake_case fields), while raw JSON payloads use
|
||||
dicts with camelCase keys — support both so results survive
|
||||
regardless of how they were fetched.
|
||||
"""
|
||||
if isinstance(item, dict):
|
||||
memory = item.get("memory", "")
|
||||
updated_at = item.get("updatedAt", "")
|
||||
else:
|
||||
memory = getattr(item, "memory", None) or ""
|
||||
updated_at = getattr(item, "updated_at", None)
|
||||
if updated_at is None:
|
||||
updated_at = getattr(item, "updatedAt", "")
|
||||
|
||||
if not isinstance(memory, str):
|
||||
memory = ""
|
||||
if isinstance(updated_at, datetime):
|
||||
updated_at = updated_at.isoformat()
|
||||
elif not isinstance(updated_at, str):
|
||||
updated_at = ""
|
||||
|
||||
return memory, updated_at
|
||||
|
||||
|
||||
def deduplicate_memories(
|
||||
static: List[str],
|
||||
dynamic: List[str],
|
||||
search_results: List[Dict[str, Any]],
|
||||
) -> Dict[str, Union[List[str], List[Dict[str, Any]]]]:
|
||||
search_results: List[Any],
|
||||
) -> Dict[str, Union[List[str], List[Any]]]:
|
||||
"""Deduplicate memories. Priority: static > dynamic > search.
|
||||
|
||||
Args:
|
||||
static: List of static memory strings.
|
||||
dynamic: List of dynamic memory strings.
|
||||
search_results: List of search result dicts with 'memory' and 'updatedAt'.
|
||||
search_results: List of search results ('memory' and 'updatedAt'
|
||||
as dicts or SDK model objects).
|
||||
"""
|
||||
seen = set()
|
||||
|
||||
|
|
@ -71,10 +99,10 @@ def deduplicate_memories(
|
|||
out.append(m)
|
||||
return out
|
||||
|
||||
def unique_search(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
def unique_search(results: List[Any]) -> List[Any]:
|
||||
out = []
|
||||
for r in results:
|
||||
memory = r.get("memory", "")
|
||||
memory, _ = extract_search_result_fields(r)
|
||||
if memory and memory not in seen:
|
||||
seen.add(memory)
|
||||
out.append(r)
|
||||
|
|
@ -116,16 +144,15 @@ def format_memories_to_text(
|
|||
sections.append("## Relevant Memories")
|
||||
lines = []
|
||||
for item in search_results:
|
||||
if isinstance(item, dict):
|
||||
memory = item.get("memory", "")
|
||||
updated_at = item.get("updatedAt", "")
|
||||
time_str = format_relative_time(updated_at) if updated_at else ""
|
||||
if time_str:
|
||||
lines.append(f"- [{time_str}] {memory}")
|
||||
else:
|
||||
lines.append(f"- {memory}")
|
||||
else:
|
||||
if isinstance(item, str):
|
||||
lines.append(f"- {item}")
|
||||
continue
|
||||
memory, updated_at = extract_search_result_fields(item)
|
||||
time_str = format_relative_time(updated_at) if updated_at else ""
|
||||
if time_str:
|
||||
lines.append(f"- [{time_str}] {memory}")
|
||||
else:
|
||||
lines.append(f"- {memory}")
|
||||
sections.append("\n".join(lines))
|
||||
|
||||
if not sections:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
"""Regression tests for SDK model-object search results.
|
||||
|
||||
The Supermemory SDK returns search results as pydantic models (attribute
|
||||
access), not dicts. deduplicate_memories used to call r.get() on them,
|
||||
raising AttributeError and killing memory injection for any user whose
|
||||
profile lookup returned search results.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from supermemory_cartesia.utils import (
|
||||
deduplicate_memories,
|
||||
extract_search_result_fields,
|
||||
format_memories_to_text,
|
||||
)
|
||||
|
||||
|
||||
def _model(memory, updated_at=None):
|
||||
"""Stand-in for an SDK pydantic Result: attribute access, no .get()."""
|
||||
return SimpleNamespace(memory=memory, updated_at=updated_at)
|
||||
|
||||
|
||||
class TestModelObjectSearchResults(unittest.TestCase):
|
||||
def test_deduplicates_model_objects_without_crashing(self):
|
||||
results = [
|
||||
_model("User likes Python"),
|
||||
_model("User likes Python"),
|
||||
_model("User works remotely"),
|
||||
]
|
||||
deduped = deduplicate_memories(static=[], dynamic=[], search_results=results)
|
||||
memories = [
|
||||
extract_search_result_fields(r)[0] for r in deduped["search_results"]
|
||||
]
|
||||
self.assertEqual(memories, ["User likes Python", "User works remotely"])
|
||||
|
||||
def test_profile_entries_still_win_over_model_search_results(self):
|
||||
deduped = deduplicate_memories(
|
||||
static=["User likes Python"],
|
||||
dynamic=[],
|
||||
search_results=[
|
||||
_model("User likes Python"),
|
||||
_model("User prefers async"),
|
||||
],
|
||||
)
|
||||
self.assertEqual(deduped["static"], ["User likes Python"])
|
||||
self.assertEqual(
|
||||
[extract_search_result_fields(r)[0] for r in deduped["search_results"]],
|
||||
["User prefers async"],
|
||||
)
|
||||
|
||||
def test_format_renders_memory_text_not_object_repr(self):
|
||||
deduped = deduplicate_memories(
|
||||
static=[], dynamic=[], search_results=[_model("User prefers async")]
|
||||
)
|
||||
text = format_memories_to_text(deduped)
|
||||
self.assertIn("- User prefers async", text)
|
||||
self.assertNotIn("namespace", text)
|
||||
|
||||
def test_dict_results_keep_working(self):
|
||||
deduped = deduplicate_memories(
|
||||
static=[],
|
||||
dynamic=[],
|
||||
search_results=[
|
||||
{"memory": "From a dict", "updatedAt": "2026-01-01T00:00:00Z"}
|
||||
],
|
||||
)
|
||||
text = format_memories_to_text(deduped)
|
||||
self.assertIn("From a dict", text)
|
||||
|
||||
def test_extract_handles_datetime_updated_at(self):
|
||||
item = _model("x", updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc))
|
||||
memory, updated_at = extract_search_result_fields(item)
|
||||
self.assertEqual(memory, "x")
|
||||
self.assertTrue(updated_at.startswith("2026-01-01"))
|
||||
|
||||
def test_extract_tolerates_missing_fields(self):
|
||||
memory, updated_at = extract_search_result_fields(SimpleNamespace())
|
||||
self.assertEqual(memory, "")
|
||||
self.assertEqual(updated_at, "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -49,17 +49,45 @@ def format_relative_time(iso_timestamp: str) -> str:
|
|||
return ""
|
||||
|
||||
|
||||
def extract_search_result_fields(item: Any) -> tuple[str, str]:
|
||||
"""Extract (memory, updatedAt) from a search result.
|
||||
|
||||
The Supermemory SDK returns search results as pydantic models
|
||||
(attribute access, snake_case fields), while raw JSON payloads use
|
||||
dicts with camelCase keys — support both so results survive
|
||||
regardless of how they were fetched.
|
||||
"""
|
||||
if isinstance(item, dict):
|
||||
memory = item.get("memory", "")
|
||||
updated_at = item.get("updatedAt", "")
|
||||
else:
|
||||
memory = getattr(item, "memory", None) or ""
|
||||
updated_at = getattr(item, "updated_at", None)
|
||||
if updated_at is None:
|
||||
updated_at = getattr(item, "updatedAt", "")
|
||||
|
||||
if not isinstance(memory, str):
|
||||
memory = ""
|
||||
if isinstance(updated_at, datetime):
|
||||
updated_at = updated_at.isoformat()
|
||||
elif not isinstance(updated_at, str):
|
||||
updated_at = ""
|
||||
|
||||
return memory, updated_at
|
||||
|
||||
|
||||
def deduplicate_memories(
|
||||
static: List[str],
|
||||
dynamic: List[str],
|
||||
search_results: List[Dict[str, Any]],
|
||||
) -> Dict[str, Union[List[str], List[Dict[str, Any]]]]:
|
||||
search_results: List[Any],
|
||||
) -> Dict[str, Union[List[str], List[Any]]]:
|
||||
"""Deduplicate memories. Priority: static > dynamic > search.
|
||||
|
||||
Args:
|
||||
static: List of static memory strings.
|
||||
dynamic: List of dynamic memory strings.
|
||||
search_results: List of search result dicts with 'memory' and 'updatedAt'.
|
||||
search_results: List of search results ('memory' and 'updatedAt'
|
||||
as dicts or SDK model objects).
|
||||
"""
|
||||
seen = set()
|
||||
|
||||
|
|
@ -71,10 +99,10 @@ def deduplicate_memories(
|
|||
out.append(m)
|
||||
return out
|
||||
|
||||
def unique_search(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
def unique_search(results: List[Any]) -> List[Any]:
|
||||
out = []
|
||||
for r in results:
|
||||
memory = r.get("memory", "")
|
||||
memory, _ = extract_search_result_fields(r)
|
||||
if memory and memory not in seen:
|
||||
seen.add(memory)
|
||||
out.append(r)
|
||||
|
|
@ -116,16 +144,15 @@ def format_memories_to_text(
|
|||
sections.append("## Relevant Memories")
|
||||
lines = []
|
||||
for item in search_results:
|
||||
if isinstance(item, dict):
|
||||
memory = item.get("memory", "")
|
||||
updated_at = item.get("updatedAt", "")
|
||||
time_str = format_relative_time(updated_at) if updated_at else ""
|
||||
if time_str:
|
||||
lines.append(f"- [{time_str}] {memory}")
|
||||
else:
|
||||
lines.append(f"- {memory}")
|
||||
else:
|
||||
if isinstance(item, str):
|
||||
lines.append(f"- {item}")
|
||||
continue
|
||||
memory, updated_at = extract_search_result_fields(item)
|
||||
time_str = format_relative_time(updated_at) if updated_at else ""
|
||||
if time_str:
|
||||
lines.append(f"- [{time_str}] {memory}")
|
||||
else:
|
||||
lines.append(f"- {memory}")
|
||||
sections.append("\n".join(lines))
|
||||
|
||||
if not sections:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
"""Regression tests for SDK model-object search results.
|
||||
|
||||
The Supermemory SDK returns search results as pydantic models (attribute
|
||||
access), not dicts. deduplicate_memories used to call r.get() on them,
|
||||
raising AttributeError and killing memory injection for any user whose
|
||||
profile lookup returned search results.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from supermemory_pipecat.utils import (
|
||||
deduplicate_memories,
|
||||
extract_search_result_fields,
|
||||
format_memories_to_text,
|
||||
)
|
||||
|
||||
|
||||
def _model(memory, updated_at=None):
|
||||
"""Stand-in for an SDK pydantic Result: attribute access, no .get()."""
|
||||
return SimpleNamespace(memory=memory, updated_at=updated_at)
|
||||
|
||||
|
||||
class TestModelObjectSearchResults(unittest.TestCase):
|
||||
def test_deduplicates_model_objects_without_crashing(self):
|
||||
results = [
|
||||
_model("User likes Python"),
|
||||
_model("User likes Python"),
|
||||
_model("User works remotely"),
|
||||
]
|
||||
deduped = deduplicate_memories(static=[], dynamic=[], search_results=results)
|
||||
memories = [
|
||||
extract_search_result_fields(r)[0] for r in deduped["search_results"]
|
||||
]
|
||||
self.assertEqual(memories, ["User likes Python", "User works remotely"])
|
||||
|
||||
def test_profile_entries_still_win_over_model_search_results(self):
|
||||
deduped = deduplicate_memories(
|
||||
static=["User likes Python"],
|
||||
dynamic=[],
|
||||
search_results=[
|
||||
_model("User likes Python"),
|
||||
_model("User prefers async"),
|
||||
],
|
||||
)
|
||||
self.assertEqual(deduped["static"], ["User likes Python"])
|
||||
self.assertEqual(
|
||||
[extract_search_result_fields(r)[0] for r in deduped["search_results"]],
|
||||
["User prefers async"],
|
||||
)
|
||||
|
||||
def test_format_renders_memory_text_not_object_repr(self):
|
||||
deduped = deduplicate_memories(
|
||||
static=[], dynamic=[], search_results=[_model("User prefers async")]
|
||||
)
|
||||
text = format_memories_to_text(deduped)
|
||||
self.assertIn("- User prefers async", text)
|
||||
self.assertNotIn("namespace", text)
|
||||
|
||||
def test_dict_results_keep_working(self):
|
||||
deduped = deduplicate_memories(
|
||||
static=[],
|
||||
dynamic=[],
|
||||
search_results=[
|
||||
{"memory": "From a dict", "updatedAt": "2026-01-01T00:00:00Z"}
|
||||
],
|
||||
)
|
||||
text = format_memories_to_text(deduped)
|
||||
self.assertIn("From a dict", text)
|
||||
|
||||
def test_extract_handles_datetime_updated_at(self):
|
||||
item = _model("x", updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc))
|
||||
memory, updated_at = extract_search_result_fields(item)
|
||||
self.assertEqual(memory, "x")
|
||||
self.assertTrue(updated_at.startswith("2026-01-01"))
|
||||
|
||||
def test_extract_tolerates_missing_fields(self):
|
||||
memory, updated_at = extract_search_result_fields(SimpleNamespace())
|
||||
self.assertEqual(memory, "")
|
||||
self.assertEqual(updated_at, "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Reference in a new issue