supermemory/packages/pipecat-sdk-python/tests/test_empty_profile.py
Dhravya 03773c4f2e
feat(python-sdks): SDK-level cross-source memory deduplication (#1532)
## Stack Context

Part 2 of a 3-PR stack moving memory deduplication into the SDKs. See `sdk-dedup/tools-ts` (parent) for the full context and the TypeScript implementation this mirrors.

## What?

Port the normalized, priority-ordered (`static > dynamic > search`) profile deduplication into the Python SDKs.

- Each request injects one **owned memory block that replaces** the prior block rather than accumulating.
- Dedup is **request-local** (no shared state), so it stays correct under concurrency.

Covers OpenAI, Agent Framework (middleware + context provider), Cartesia, and Pipecat.

## Why?

Keeps the Python SDKs at behavioral parity with the TypeScript SDK so all integrations deduplicate memory the same way.

## Testing

- OpenAI: 31 passed, 11 skipped (live)
- Agent Framework: 59 passed
- Cartesia: 8 passed
- Pipecat: 8 passed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes memory formatting and system-prompt injection across multiple SDK integrations; incorrect dedup or replacement could alter LLM context, but there is no auth or data-store risk.
>
> **Overview**
> Ports **normalized cross-source memory deduplication** and **replace-not-append injection** into the Python OpenAI, Agent Framework, Cartesia, and Pipecat packages so they match the TypeScript SDK behavior.
>
> **Deduplication** uses request-local keys: strip optional `[YYYY-MM-DD]` prefixes, normalize whitespace, and compare with `casefold`, with priority **static → dynamic → search**. In **`query` mode**, profile static/dynamic are excluded from dedup input so facts that only appear in search (or overlap profile) are not dropped before formatting.
>
> **Injection** no longer appends memory text every turn. OpenAI and Agent Framework middleware **strip prior owned `<supermemory context="user-memories" readonly>` blocks** and **replace** them once per request while keeping the caller’s system instructions; extra system messages lose stale blocks only. New helpers (`strip`/`replace`/`wrap`) live in each package’s utils.
>
> Tests cover normalized fact variants, query-mode search retention, and stale block replacement.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 42f308b224. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-09-01 06:10:36 +00:00

156 lines
4.8 KiB
Python

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
_install_test_stubs()
from supermemory_pipecat.service import SupermemoryPipecatService
class _MockSupermemoryClient:
def __init__(self, response):
self.profile = AsyncMock(return_value=response)
class TestSupermemoryPipecatNullProfile(unittest.IsolatedAsyncioTestCase):
async def test_retrieve_memories_handles_null_profile(self) -> None:
service = SupermemoryPipecatService(api_key="mock_key", user_id="new_user_123")
response = SimpleNamespace(profile=None, search_results=None)
service._supermemory_client = _MockSupermemoryClient(response)
result = await service._retrieve_memories("Hello world")
self.assertEqual(
result,
{
"profile": {"static": [], "dynamic": []},
"search_results": [],
},
)
def test_query_mode_keeps_search_fact_also_present_in_profile(self) -> None:
fact = "User likes machine learning projects"
service = SupermemoryPipecatService(
api_key="mock_key",
user_id="user-123",
session_id="conversation-456",
params=SupermemoryPipecatService.InputParams(mode="query"),
)
class Context:
def __init__(self):
self.messages = [{"role": "user", "content": "What do I like?"}]
def get_messages(self):
return self.messages
def add_message(self, message):
self.messages.append(message)
context = Context()
service._enhance_context_with_memories(
context,
"What do I like?",
{
"profile": {"static": [fact], "dynamic": []},
"search_results": [SimpleNamespace(memory=fact)],
},
)
self.assertTrue(
any(fact in message.get("content", "") for message in context.messages)
)