mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
fix(python-sdks): handle pydantic search results in memory dedup helpers
The pipecat, cartesia, and agent-framework packages passed search results into dedup/format helpers written for plain dicts, but typed SDK results are pydantic models (snake_case attributes, no dict interface). pipecat and cartesia crashed with AttributeError — swallowed upstream, so no memories were ever injected for users with search results — while agent-framework silently dropped every search-result memory. Extract memory fields through a tolerant accessor that accepts both the camelCase dicts returned by the profile endpoint and SDK result models, so the helpers work regardless of installed SDK version. The pipecat/cartesia dependency stubs move from test_empty_profile.py into a shared tests/conftest.py so the new utils tests can reuse them. Fixes #1266
This commit is contained in:
parent
2731de5c06
commit
bd1e767b74
10 changed files with 441 additions and 164 deletions
|
|
@ -92,14 +92,15 @@ def deduplicate_memories(
|
|||
def extract_memory_text(item: Any) -> Optional[str]:
|
||||
if item is None:
|
||||
return None
|
||||
if isinstance(item, dict):
|
||||
memory = item.get("memory")
|
||||
if isinstance(memory, str):
|
||||
trimmed = memory.strip()
|
||||
return trimmed if trimmed else None
|
||||
return None
|
||||
if isinstance(item, str):
|
||||
trimmed = item.strip()
|
||||
memory: Any = item
|
||||
elif isinstance(item, dict):
|
||||
memory = item.get("memory")
|
||||
else:
|
||||
# SDK search results are pydantic models with a `memory` attribute
|
||||
memory = getattr(item, "memory", None)
|
||||
if isinstance(memory, str):
|
||||
trimmed = memory.strip()
|
||||
return trimmed if trimmed else None
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -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_items(self) -> None:
|
||||
# SDK search results are pydantic models with a `memory` attribute,
|
||||
# not dicts — they must not be silently dropped (#1266).
|
||||
result = deduplicate_memories(
|
||||
search_results=[SimpleNamespace(memory="User prefers async")],
|
||||
)
|
||||
assert result.search_results == ["User prefers async"]
|
||||
|
||||
def test_model_items_deduplicate_against_static(self) -> None:
|
||||
result = deduplicate_memories(
|
||||
static=[{"memory": "User likes Python"}],
|
||||
search_results=[
|
||||
SimpleNamespace(memory="User likes Python"),
|
||||
SimpleNamespace(memory="User prefers async"),
|
||||
],
|
||||
)
|
||||
assert result.search_results == ["User prefers async"]
|
||||
|
||||
def test_model_items_without_memory_text_filtered(self) -> None:
|
||||
result = deduplicate_memories(
|
||||
search_results=[SimpleNamespace(memory=None), SimpleNamespace(memory=" ")],
|
||||
)
|
||||
assert result.search_results == []
|
||||
|
||||
|
||||
class TestConvertProfileToMarkdown:
|
||||
def test_empty_profile(self) -> None:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,27 @@
|
|||
"""Utility functions for Supermemory Cartesia integration."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Union
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
def _get_result_field(result: Any, *keys: str) -> Any:
|
||||
"""Read a field from a search result that may be a dict or an SDK model.
|
||||
|
||||
The profile endpoint returns search results as plain dicts with camelCase
|
||||
keys, while the typed SDK models expose the same data as snake_case
|
||||
attributes. Accept both shapes so callers don't depend on the SDK version.
|
||||
"""
|
||||
if isinstance(result, dict):
|
||||
for key in keys:
|
||||
value = result.get(key)
|
||||
if value is not None:
|
||||
return value
|
||||
return None
|
||||
for key in keys:
|
||||
value = getattr(result, key, None)
|
||||
if value is not None:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def get_last_user_message(messages: List[Dict[str, str]]) -> str | None:
|
||||
|
|
@ -52,14 +72,15 @@ def format_relative_time(iso_timestamp: str) -> str:
|
|||
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, 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 with 'memory' and 'updatedAt',
|
||||
either as dicts or as SDK result models.
|
||||
"""
|
||||
seen = set()
|
||||
|
||||
|
|
@ -71,10 +92,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 = _get_result_field(r, "memory")
|
||||
if memory and memory not in seen:
|
||||
seen.add(memory)
|
||||
out.append(r)
|
||||
|
|
@ -88,7 +109,7 @@ def deduplicate_memories(
|
|||
|
||||
|
||||
def format_memories_to_text(
|
||||
memories: Dict[str, Union[List[str], List[Dict[str, Any]]]],
|
||||
memories: Dict[str, List[Any]],
|
||||
system_prompt: str = "Based on previous conversations, I recall:\n\n",
|
||||
include_static: bool = True,
|
||||
include_dynamic: bool = True,
|
||||
|
|
@ -116,16 +137,16 @@ 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 = _get_result_field(item, "memory") or ""
|
||||
updated_at = _get_result_field(item, "updatedAt", "updated_at") or ""
|
||||
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:
|
||||
|
|
|
|||
46
packages/cartesia-sdk-python/tests/conftest.py
Normal file
46
packages/cartesia-sdk-python/tests/conftest.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"""Shared pytest fixtures: stub out heavy optional dependencies.
|
||||
|
||||
The stubs are installed at collection time so test modules can import the
|
||||
package without pipecat/cartesia, loguru, or pydantic present.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
def _install_test_stubs() -> None:
|
||||
if "loguru" not in sys.modules:
|
||||
loguru_module = types.ModuleType("loguru")
|
||||
|
||||
class _Logger:
|
||||
def info(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
def warning(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
def error(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
loguru_module.logger = _Logger()
|
||||
sys.modules["loguru"] = loguru_module
|
||||
|
||||
if "pydantic" not in sys.modules:
|
||||
pydantic_module = types.ModuleType("pydantic")
|
||||
|
||||
class BaseModel:
|
||||
def __init__(self, **kwargs):
|
||||
for key, value in kwargs.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
def Field(*, default=None, **_kwargs):
|
||||
return default
|
||||
|
||||
pydantic_module.BaseModel = BaseModel
|
||||
pydantic_module.Field = Field
|
||||
sys.modules["pydantic"] = pydantic_module
|
||||
|
||||
|
||||
_install_test_stubs()
|
||||
|
|
@ -1,44 +1,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
|
||||
def _install_test_stubs() -> None:
|
||||
if "loguru" not in sys.modules:
|
||||
loguru_module = types.ModuleType("loguru")
|
||||
|
||||
class _Logger:
|
||||
def info(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
def warning(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
def error(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
loguru_module.logger = _Logger()
|
||||
sys.modules["loguru"] = loguru_module
|
||||
|
||||
if "pydantic" not in sys.modules:
|
||||
pydantic_module = types.ModuleType("pydantic")
|
||||
|
||||
class BaseModel:
|
||||
def __init__(self, **kwargs):
|
||||
for key, value in kwargs.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
def Field(*, default=None, **_kwargs):
|
||||
return default
|
||||
|
||||
pydantic_module.BaseModel = BaseModel
|
||||
pydantic_module.Field = Field
|
||||
sys.modules["pydantic"] = pydantic_module
|
||||
|
||||
from .conftest import _install_test_stubs
|
||||
|
||||
_install_test_stubs()
|
||||
|
||||
|
|
|
|||
92
packages/cartesia-sdk-python/tests/test_utils.py
Normal file
92
packages/cartesia-sdk-python/tests/test_utils.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"""Tests for the memory dedup/format helpers.
|
||||
|
||||
Search results reach these helpers either as plain camelCase dicts (profile
|
||||
endpoint) or as typed SDK result models (snake_case attributes, no dict
|
||||
interface). Both shapes must be handled — see issue #1266.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from .conftest import _install_test_stubs
|
||||
|
||||
_install_test_stubs()
|
||||
|
||||
from supermemory_cartesia.utils import deduplicate_memories, format_memories_to_text
|
||||
|
||||
|
||||
def _model_result(memory, updated_at=None):
|
||||
"""Stand-in for an SDK result model: attribute access only, no .get()."""
|
||||
return SimpleNamespace(memory=memory, updated_at=updated_at)
|
||||
|
||||
|
||||
class TestDeduplicateMemories(unittest.TestCase):
|
||||
def test_dict_results_with_camel_case_keys(self) -> None:
|
||||
results = [{"memory": "User prefers async", "updatedAt": "2026-01-01T00:00:00Z"}]
|
||||
|
||||
deduplicated = deduplicate_memories(static=[], dynamic=[], search_results=results)
|
||||
|
||||
self.assertEqual(deduplicated["search_results"], results)
|
||||
|
||||
def test_model_results_use_attribute_access(self) -> None:
|
||||
result = _model_result("User prefers async")
|
||||
|
||||
deduplicated = deduplicate_memories(static=[], dynamic=[], search_results=[result])
|
||||
|
||||
self.assertEqual(deduplicated["search_results"], [result])
|
||||
|
||||
def test_model_results_deduplicate_against_profile(self) -> None:
|
||||
deduplicated = deduplicate_memories(
|
||||
static=["User prefers async"],
|
||||
dynamic=[],
|
||||
search_results=[
|
||||
_model_result("User prefers async"),
|
||||
_model_result("User works remotely"),
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(deduplicated["static"], ["User prefers async"])
|
||||
self.assertEqual(
|
||||
[r.memory for r in deduplicated["search_results"]],
|
||||
["User works remotely"],
|
||||
)
|
||||
|
||||
def test_results_without_memory_are_dropped(self) -> None:
|
||||
deduplicated = deduplicate_memories(
|
||||
static=[],
|
||||
dynamic=[],
|
||||
search_results=[_model_result(None), {"updatedAt": "2026-01-01T00:00:00Z"}],
|
||||
)
|
||||
|
||||
self.assertEqual(deduplicated["search_results"], [])
|
||||
|
||||
|
||||
class TestFormatMemoriesToText(unittest.TestCase):
|
||||
def _format(self, search_results) -> str:
|
||||
return format_memories_to_text(
|
||||
{"static": [], "dynamic": [], "search_results": search_results}
|
||||
)
|
||||
|
||||
def test_dict_results_render_memory_and_relative_time(self) -> None:
|
||||
text = self._format(
|
||||
[{"memory": "User prefers async", "updatedAt": "2020-01-05T00:00:00Z"}]
|
||||
)
|
||||
|
||||
self.assertIn("- [5 Jan, 2020] User prefers async", text)
|
||||
|
||||
def test_model_results_render_memory_and_relative_time(self) -> None:
|
||||
text = self._format([_model_result("User prefers async", "2020-01-05T00:00:00Z")])
|
||||
|
||||
self.assertIn("- [5 Jan, 2020] User prefers async", text)
|
||||
|
||||
def test_model_results_without_timestamp_render_memory_only(self) -> None:
|
||||
text = self._format([_model_result("User prefers async")])
|
||||
|
||||
self.assertIn("- User prefers async", text)
|
||||
|
||||
def test_string_results_render_verbatim(self) -> None:
|
||||
text = self._format(["User prefers async"])
|
||||
|
||||
self.assertIn("- User prefers async", text)
|
||||
|
|
@ -1,7 +1,27 @@
|
|||
"""Utility functions for Supermemory Pipecat integration."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Union
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
def _get_result_field(result: Any, *keys: str) -> Any:
|
||||
"""Read a field from a search result that may be a dict or an SDK model.
|
||||
|
||||
The profile endpoint returns search results as plain dicts with camelCase
|
||||
keys, while the typed SDK models expose the same data as snake_case
|
||||
attributes. Accept both shapes so callers don't depend on the SDK version.
|
||||
"""
|
||||
if isinstance(result, dict):
|
||||
for key in keys:
|
||||
value = result.get(key)
|
||||
if value is not None:
|
||||
return value
|
||||
return None
|
||||
for key in keys:
|
||||
value = getattr(result, key, None)
|
||||
if value is not None:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def get_last_user_message(messages: List[Dict[str, str]]) -> str | None:
|
||||
|
|
@ -52,14 +72,15 @@ def format_relative_time(iso_timestamp: str) -> str:
|
|||
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, 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 with 'memory' and 'updatedAt',
|
||||
either as dicts or as SDK result models.
|
||||
"""
|
||||
seen = set()
|
||||
|
||||
|
|
@ -71,10 +92,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 = _get_result_field(r, "memory")
|
||||
if memory and memory not in seen:
|
||||
seen.add(memory)
|
||||
out.append(r)
|
||||
|
|
@ -88,7 +109,7 @@ def deduplicate_memories(
|
|||
|
||||
|
||||
def format_memories_to_text(
|
||||
memories: Dict[str, Union[List[str], List[Dict[str, Any]]]],
|
||||
memories: Dict[str, List[Any]],
|
||||
system_prompt: str = "Based on previous conversations, I recall:\n\n",
|
||||
include_static: bool = True,
|
||||
include_dynamic: bool = True,
|
||||
|
|
@ -116,16 +137,16 @@ 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 = _get_result_field(item, "memory") or ""
|
||||
updated_at = _get_result_field(item, "updatedAt", "updated_at") or ""
|
||||
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:
|
||||
|
|
|
|||
101
packages/pipecat-sdk-python/tests/conftest.py
Normal file
101
packages/pipecat-sdk-python/tests/conftest.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"""Shared pytest fixtures: stub out heavy optional dependencies.
|
||||
|
||||
The stubs are installed at collection time so test modules can import the
|
||||
package without pipecat/cartesia, loguru, or pydantic present.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
def _install_test_stubs() -> None:
|
||||
if "loguru" not in sys.modules:
|
||||
loguru_module = types.ModuleType("loguru")
|
||||
|
||||
class _Logger:
|
||||
def warning(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
def error(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
loguru_module.logger = _Logger()
|
||||
sys.modules["loguru"] = loguru_module
|
||||
|
||||
if "pydantic" not in sys.modules:
|
||||
pydantic_module = types.ModuleType("pydantic")
|
||||
|
||||
class BaseModel:
|
||||
def __init__(self, **kwargs):
|
||||
for key, value in kwargs.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
def Field(*, default=None, **_kwargs):
|
||||
return default
|
||||
|
||||
pydantic_module.BaseModel = BaseModel
|
||||
pydantic_module.Field = Field
|
||||
sys.modules["pydantic"] = pydantic_module
|
||||
|
||||
if "pipecat" not in sys.modules:
|
||||
pipecat_module = types.ModuleType("pipecat")
|
||||
sys.modules["pipecat"] = pipecat_module
|
||||
|
||||
frames_module = types.ModuleType("pipecat.frames.frames")
|
||||
|
||||
class Frame: # pragma: no cover - import stub
|
||||
pass
|
||||
|
||||
class InputAudioRawFrame: # pragma: no cover - import stub
|
||||
pass
|
||||
|
||||
class LLMContextFrame: # pragma: no cover - import stub
|
||||
pass
|
||||
|
||||
class LLMMessagesFrame: # pragma: no cover - import stub
|
||||
pass
|
||||
|
||||
frames_module.Frame = Frame
|
||||
frames_module.InputAudioRawFrame = InputAudioRawFrame
|
||||
frames_module.LLMContextFrame = LLMContextFrame
|
||||
frames_module.LLMMessagesFrame = LLMMessagesFrame
|
||||
|
||||
llm_context_module = types.ModuleType("pipecat.processors.aggregators.llm_context")
|
||||
|
||||
class LLMContext: # pragma: no cover - import stub
|
||||
pass
|
||||
|
||||
llm_context_module.LLMContext = LLMContext
|
||||
|
||||
openai_context_module = types.ModuleType(
|
||||
"pipecat.processors.aggregators.openai_llm_context"
|
||||
)
|
||||
|
||||
class OpenAILLMContextFrame: # pragma: no cover - import stub
|
||||
pass
|
||||
|
||||
openai_context_module.OpenAILLMContextFrame = OpenAILLMContextFrame
|
||||
|
||||
frame_processor_module = types.ModuleType("pipecat.processors.frame_processor")
|
||||
|
||||
class FrameDirection: # pragma: no cover - import stub
|
||||
pass
|
||||
|
||||
class FrameProcessor:
|
||||
def __init__(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
frame_processor_module.FrameDirection = FrameDirection
|
||||
frame_processor_module.FrameProcessor = FrameProcessor
|
||||
|
||||
sys.modules["pipecat.frames.frames"] = frames_module
|
||||
sys.modules["pipecat.processors.aggregators.llm_context"] = llm_context_module
|
||||
sys.modules[
|
||||
"pipecat.processors.aggregators.openai_llm_context"
|
||||
] = openai_context_module
|
||||
sys.modules["pipecat.processors.frame_processor"] = frame_processor_module
|
||||
|
||||
|
||||
_install_test_stubs()
|
||||
|
|
@ -1,99 +1,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
|
||||
def _install_test_stubs() -> None:
|
||||
if "loguru" not in sys.modules:
|
||||
loguru_module = types.ModuleType("loguru")
|
||||
|
||||
class _Logger:
|
||||
def warning(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
def error(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
loguru_module.logger = _Logger()
|
||||
sys.modules["loguru"] = loguru_module
|
||||
|
||||
if "pydantic" not in sys.modules:
|
||||
pydantic_module = types.ModuleType("pydantic")
|
||||
|
||||
class BaseModel:
|
||||
def __init__(self, **kwargs):
|
||||
for key, value in kwargs.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
def Field(*, default=None, **_kwargs):
|
||||
return default
|
||||
|
||||
pydantic_module.BaseModel = BaseModel
|
||||
pydantic_module.Field = Field
|
||||
sys.modules["pydantic"] = pydantic_module
|
||||
|
||||
if "pipecat" not in sys.modules:
|
||||
pipecat_module = types.ModuleType("pipecat")
|
||||
sys.modules["pipecat"] = pipecat_module
|
||||
|
||||
frames_module = types.ModuleType("pipecat.frames.frames")
|
||||
|
||||
class Frame: # pragma: no cover - import stub
|
||||
pass
|
||||
|
||||
class InputAudioRawFrame: # pragma: no cover - import stub
|
||||
pass
|
||||
|
||||
class LLMContextFrame: # pragma: no cover - import stub
|
||||
pass
|
||||
|
||||
class LLMMessagesFrame: # pragma: no cover - import stub
|
||||
pass
|
||||
|
||||
frames_module.Frame = Frame
|
||||
frames_module.InputAudioRawFrame = InputAudioRawFrame
|
||||
frames_module.LLMContextFrame = LLMContextFrame
|
||||
frames_module.LLMMessagesFrame = LLMMessagesFrame
|
||||
|
||||
llm_context_module = types.ModuleType("pipecat.processors.aggregators.llm_context")
|
||||
|
||||
class LLMContext: # pragma: no cover - import stub
|
||||
pass
|
||||
|
||||
llm_context_module.LLMContext = LLMContext
|
||||
|
||||
openai_context_module = types.ModuleType(
|
||||
"pipecat.processors.aggregators.openai_llm_context"
|
||||
)
|
||||
|
||||
class OpenAILLMContextFrame: # pragma: no cover - import stub
|
||||
pass
|
||||
|
||||
openai_context_module.OpenAILLMContextFrame = OpenAILLMContextFrame
|
||||
|
||||
frame_processor_module = types.ModuleType("pipecat.processors.frame_processor")
|
||||
|
||||
class FrameDirection: # pragma: no cover - import stub
|
||||
pass
|
||||
|
||||
class FrameProcessor:
|
||||
def __init__(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
frame_processor_module.FrameDirection = FrameDirection
|
||||
frame_processor_module.FrameProcessor = FrameProcessor
|
||||
|
||||
sys.modules["pipecat.frames.frames"] = frames_module
|
||||
sys.modules["pipecat.processors.aggregators.llm_context"] = llm_context_module
|
||||
sys.modules[
|
||||
"pipecat.processors.aggregators.openai_llm_context"
|
||||
] = openai_context_module
|
||||
sys.modules["pipecat.processors.frame_processor"] = frame_processor_module
|
||||
|
||||
from .conftest import _install_test_stubs
|
||||
|
||||
_install_test_stubs()
|
||||
|
||||
|
|
|
|||
92
packages/pipecat-sdk-python/tests/test_utils.py
Normal file
92
packages/pipecat-sdk-python/tests/test_utils.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"""Tests for the memory dedup/format helpers.
|
||||
|
||||
Search results reach these helpers either as plain camelCase dicts (profile
|
||||
endpoint) or as typed SDK result models (snake_case attributes, no dict
|
||||
interface). Both shapes must be handled — see issue #1266.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from .conftest import _install_test_stubs
|
||||
|
||||
_install_test_stubs()
|
||||
|
||||
from supermemory_pipecat.utils import deduplicate_memories, format_memories_to_text
|
||||
|
||||
|
||||
def _model_result(memory, updated_at=None):
|
||||
"""Stand-in for an SDK result model: attribute access only, no .get()."""
|
||||
return SimpleNamespace(memory=memory, updated_at=updated_at)
|
||||
|
||||
|
||||
class TestDeduplicateMemories(unittest.TestCase):
|
||||
def test_dict_results_with_camel_case_keys(self) -> None:
|
||||
results = [{"memory": "User prefers async", "updatedAt": "2026-01-01T00:00:00Z"}]
|
||||
|
||||
deduplicated = deduplicate_memories(static=[], dynamic=[], search_results=results)
|
||||
|
||||
self.assertEqual(deduplicated["search_results"], results)
|
||||
|
||||
def test_model_results_use_attribute_access(self) -> None:
|
||||
result = _model_result("User prefers async")
|
||||
|
||||
deduplicated = deduplicate_memories(static=[], dynamic=[], search_results=[result])
|
||||
|
||||
self.assertEqual(deduplicated["search_results"], [result])
|
||||
|
||||
def test_model_results_deduplicate_against_profile(self) -> None:
|
||||
deduplicated = deduplicate_memories(
|
||||
static=["User prefers async"],
|
||||
dynamic=[],
|
||||
search_results=[
|
||||
_model_result("User prefers async"),
|
||||
_model_result("User works remotely"),
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(deduplicated["static"], ["User prefers async"])
|
||||
self.assertEqual(
|
||||
[r.memory for r in deduplicated["search_results"]],
|
||||
["User works remotely"],
|
||||
)
|
||||
|
||||
def test_results_without_memory_are_dropped(self) -> None:
|
||||
deduplicated = deduplicate_memories(
|
||||
static=[],
|
||||
dynamic=[],
|
||||
search_results=[_model_result(None), {"updatedAt": "2026-01-01T00:00:00Z"}],
|
||||
)
|
||||
|
||||
self.assertEqual(deduplicated["search_results"], [])
|
||||
|
||||
|
||||
class TestFormatMemoriesToText(unittest.TestCase):
|
||||
def _format(self, search_results) -> str:
|
||||
return format_memories_to_text(
|
||||
{"static": [], "dynamic": [], "search_results": search_results}
|
||||
)
|
||||
|
||||
def test_dict_results_render_memory_and_relative_time(self) -> None:
|
||||
text = self._format(
|
||||
[{"memory": "User prefers async", "updatedAt": "2020-01-05T00:00:00Z"}]
|
||||
)
|
||||
|
||||
self.assertIn("- [5 Jan, 2020] User prefers async", text)
|
||||
|
||||
def test_model_results_render_memory_and_relative_time(self) -> None:
|
||||
text = self._format([_model_result("User prefers async", "2020-01-05T00:00:00Z")])
|
||||
|
||||
self.assertIn("- [5 Jan, 2020] User prefers async", text)
|
||||
|
||||
def test_model_results_without_timestamp_render_memory_only(self) -> None:
|
||||
text = self._format([_model_result("User prefers async")])
|
||||
|
||||
self.assertIn("- User prefers async", text)
|
||||
|
||||
def test_string_results_render_verbatim(self) -> None:
|
||||
text = self._format(["User prefers async"])
|
||||
|
||||
self.assertIn("- User prefers async", text)
|
||||
Loading…
Add table
Reference in a new issue