mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-05 08:06:19 +00:00
## 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 -->
115 lines
3.9 KiB
Python
115 lines
3.9 KiB
Python
"""Tests for utility functions."""
|
|
|
|
import pytest
|
|
|
|
from supermemory_agent_framework.utils import (
|
|
DeduplicatedMemories,
|
|
SimpleLogger,
|
|
convert_profile_to_markdown,
|
|
create_logger,
|
|
deduplicate_memories,
|
|
)
|
|
|
|
|
|
class TestDeduplicateMemories:
|
|
def test_empty_inputs(self) -> None:
|
|
result = deduplicate_memories()
|
|
assert result.static == []
|
|
assert result.dynamic == []
|
|
assert result.search_results == []
|
|
|
|
def test_static_only(self) -> None:
|
|
result = deduplicate_memories(
|
|
static=[{"memory": "User likes Python"}],
|
|
)
|
|
assert result.static == ["User likes Python"]
|
|
assert result.dynamic == []
|
|
assert result.search_results == []
|
|
|
|
def test_deduplication_priority(self) -> None:
|
|
result = deduplicate_memories(
|
|
static=[{"memory": "User likes Python"}],
|
|
dynamic=[{"memory": "User likes Python"}, {"memory": "User works remotely"}],
|
|
search_results=[{"memory": "User likes Python"}, {"memory": "User prefers async"}],
|
|
)
|
|
assert result.static == ["User likes Python"]
|
|
assert result.dynamic == ["User works remotely"]
|
|
assert result.search_results == ["User prefers async"]
|
|
|
|
def test_string_format(self) -> None:
|
|
result = deduplicate_memories(
|
|
static=["User likes Python"],
|
|
dynamic=["User works remotely"],
|
|
)
|
|
assert result.static == ["User likes Python"]
|
|
assert result.dynamic == ["User works remotely"]
|
|
|
|
def test_empty_strings_filtered(self) -> None:
|
|
result = deduplicate_memories(
|
|
static=["", " ", "User likes Python"],
|
|
)
|
|
assert result.static == ["User likes Python"]
|
|
|
|
def test_none_items_filtered(self) -> None:
|
|
result = deduplicate_memories(
|
|
static=[None, {"memory": "valid"}],
|
|
)
|
|
assert result.static == ["valid"]
|
|
|
|
def test_normalized_fact_variants(self) -> None:
|
|
result = deduplicate_memories(
|
|
static=["User likes Python", " user likes python "],
|
|
dynamic=["[2026-08-10] USER LIKES PYTHON"],
|
|
)
|
|
assert result.static == ["User likes Python"]
|
|
assert result.dynamic == []
|
|
|
|
|
|
class TestConvertProfileToMarkdown:
|
|
def test_empty_profile(self) -> None:
|
|
result = convert_profile_to_markdown({"profile": {}})
|
|
assert result == ""
|
|
|
|
def test_static_only(self) -> None:
|
|
result = convert_profile_to_markdown(
|
|
{"profile": {"static": ["Likes Python", "Lives in SF"]}}
|
|
)
|
|
assert "## Static Profile" in result
|
|
assert "- Likes Python" in result
|
|
assert "- Lives in SF" in result
|
|
|
|
def test_both_sections(self) -> None:
|
|
result = convert_profile_to_markdown(
|
|
{
|
|
"profile": {
|
|
"static": ["Likes Python"],
|
|
"dynamic": ["Asked about AI"],
|
|
}
|
|
}
|
|
)
|
|
assert "## Static Profile" in result
|
|
assert "## Dynamic Profile" in result
|
|
|
|
|
|
class TestLogger:
|
|
def test_verbose_logger(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
logger = SimpleLogger(verbose=True)
|
|
logger.info("test message")
|
|
captured = capsys.readouterr()
|
|
assert "[supermemory] test message" in captured.out
|
|
|
|
def test_silent_logger(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
logger = SimpleLogger(verbose=False)
|
|
logger.info("test message")
|
|
captured = capsys.readouterr()
|
|
assert captured.out == ""
|
|
|
|
def test_error_prefix(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
logger = SimpleLogger(verbose=True)
|
|
logger.error("something failed")
|
|
captured = capsys.readouterr()
|
|
assert "ERROR:" in captured.out
|
|
|
|
def test_create_logger(self) -> None:
|
|
logger = create_logger(True)
|
|
assert isinstance(logger, SimpleLogger)
|