mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
fix(middleware): update deduplication logic for profile memories in query mode (#1243)
This commit is contained in:
parent
566be20898
commit
86c3ad69d1
5 changed files with 168 additions and 6 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import type OpenAI from "openai"
|
||||
import Supermemory from "supermemory"
|
||||
import { addConversation } from "../conversations-client"
|
||||
import { deduplicateMemories } from "../tools-shared"
|
||||
import { deduplicateMemoriesForMode } from "../tools-shared"
|
||||
import { createLogger, type Logger } from "../vercel/logger"
|
||||
import { convertProfileToMarkdown } from "../vercel/util"
|
||||
|
||||
|
|
@ -184,7 +184,7 @@ const addSystemPrompt = async (
|
|||
mode,
|
||||
})
|
||||
|
||||
const deduplicated = deduplicateMemories({
|
||||
const deduplicated = deduplicateMemoriesForMode(mode, {
|
||||
static: memoriesResponse.profile.static,
|
||||
dynamic: memoriesResponse.profile.dynamic,
|
||||
searchResults: memoriesResponse.searchResults?.results,
|
||||
|
|
@ -471,7 +471,7 @@ export function createOpenAIMiddleware(
|
|||
mode,
|
||||
})
|
||||
|
||||
const deduplicated = deduplicateMemories({
|
||||
const deduplicated = deduplicateMemoriesForMode(mode, {
|
||||
static: memoriesResponse.profile.static,
|
||||
dynamic: memoriesResponse.profile.dynamic,
|
||||
searchResults: memoriesResponse.searchResults?.results,
|
||||
|
|
|
|||
78
packages/tools/src/shared/memory-client.test.ts
Normal file
78
packages/tools/src/shared/memory-client.test.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { buildMemoriesText } from "./memory-client"
|
||||
import { createLogger } from "./logger"
|
||||
|
||||
const API_KEY = "sm_test_key"
|
||||
const BASE_URL = "https://api.supermemory.ai"
|
||||
const CONTAINER_TAG = "user-123"
|
||||
|
||||
const logger = createLogger(false)
|
||||
|
||||
/** Stubs `/v4/profile` so the injected prompt can be asserted without network access. */
|
||||
function mockProfileResponse(body: unknown) {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => body,
|
||||
})
|
||||
vi.stubGlobal("fetch", fetchMock)
|
||||
return fetchMock
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe("buildMemoriesText", () => {
|
||||
// The profile is not injected in "query" mode. Deduplicating the search
|
||||
// results against it would drop a fact present in both, leaving the model
|
||||
// with nothing.
|
||||
it("injects a search result that also exists in the profile in query mode", async () => {
|
||||
mockProfileResponse({
|
||||
profile: {
|
||||
static: [{ memory: "User is allergic to peanuts" }],
|
||||
dynamic: [],
|
||||
},
|
||||
searchResults: { results: [{ memory: "User is allergic to peanuts" }] },
|
||||
})
|
||||
|
||||
const memories = await buildMemoriesText({
|
||||
containerTag: CONTAINER_TAG,
|
||||
queryText: "what should I avoid eating?",
|
||||
mode: "query",
|
||||
baseUrl: BASE_URL,
|
||||
apiKey: API_KEY,
|
||||
logger,
|
||||
})
|
||||
|
||||
expect(memories).toContain("User is allergic to peanuts")
|
||||
})
|
||||
|
||||
it("does not repeat a profile memory in the search results in full mode", async () => {
|
||||
mockProfileResponse({
|
||||
profile: {
|
||||
static: [{ memory: "User is allergic to peanuts" }],
|
||||
dynamic: [],
|
||||
},
|
||||
searchResults: {
|
||||
results: [
|
||||
{ memory: "User is allergic to peanuts" },
|
||||
{ memory: "User prefers async/await" },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const memories = await buildMemoriesText({
|
||||
containerTag: CONTAINER_TAG,
|
||||
queryText: "what should I avoid eating?",
|
||||
mode: "full",
|
||||
baseUrl: BASE_URL,
|
||||
apiKey: API_KEY,
|
||||
logger,
|
||||
})
|
||||
|
||||
expect(memories).toContain("## Static Profile")
|
||||
expect(memories).toContain("User prefers async/await")
|
||||
// Present once, under the profile — not duplicated into the search results.
|
||||
expect(memories.match(/User is allergic to peanuts/g)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { deduplicateMemories } from "../tools-shared"
|
||||
import { deduplicateMemoriesForMode } from "../tools-shared"
|
||||
import type {
|
||||
Logger,
|
||||
MemoryMode,
|
||||
|
|
@ -119,7 +119,7 @@ export const buildMemoriesText = async (
|
|||
mode,
|
||||
})
|
||||
|
||||
const deduplicated = deduplicateMemories({
|
||||
const deduplicated = deduplicateMemoriesForMode(mode, {
|
||||
static: memoriesResponse.profile.static,
|
||||
dynamic: memoriesResponse.profile.dynamic,
|
||||
searchResults: memoriesResponse.searchResults?.results,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
import { getContainerTags } from "./tools-shared"
|
||||
import { deduplicateMemoriesForMode, getContainerTags } from "./tools-shared"
|
||||
|
||||
describe("getContainerTags", () => {
|
||||
it("uses the default project when no config is provided", () => {
|
||||
|
|
@ -26,3 +26,59 @@ describe("getContainerTags", () => {
|
|||
).toThrow("either projectId or containerTags")
|
||||
})
|
||||
})
|
||||
|
||||
describe("deduplicateMemoriesForMode", () => {
|
||||
// The profile is not injected in "query" mode, so a memory that is both a
|
||||
// profile fact and a search hit must survive in the search results —
|
||||
// otherwise it is dropped from the prompt entirely.
|
||||
it("keeps a search result that duplicates a profile memory in query mode", () => {
|
||||
const deduplicated = deduplicateMemoriesForMode("query", {
|
||||
static: [{ memory: "User is allergic to peanuts" }],
|
||||
dynamic: [],
|
||||
searchResults: [{ memory: "User is allergic to peanuts" }],
|
||||
})
|
||||
|
||||
expect(deduplicated.searchResults).toEqual(["User is allergic to peanuts"])
|
||||
expect(deduplicated.static).toEqual([])
|
||||
expect(deduplicated.dynamic).toEqual([])
|
||||
})
|
||||
|
||||
it("still deduplicates within the search results in query mode", () => {
|
||||
const deduplicated = deduplicateMemoriesForMode("query", {
|
||||
static: [],
|
||||
dynamic: [],
|
||||
searchResults: [
|
||||
{ memory: "User likes TypeScript" },
|
||||
"User likes TypeScript",
|
||||
],
|
||||
})
|
||||
|
||||
expect(deduplicated.searchResults).toEqual(["User likes TypeScript"])
|
||||
})
|
||||
|
||||
it("deduplicates search results against the profile in full mode", () => {
|
||||
const deduplicated = deduplicateMemoriesForMode("full", {
|
||||
static: [{ memory: "User is allergic to peanuts" }],
|
||||
dynamic: [{ memory: "User is shipping a release today" }],
|
||||
searchResults: [
|
||||
{ memory: "User is allergic to peanuts" },
|
||||
{ memory: "User prefers async/await" },
|
||||
],
|
||||
})
|
||||
|
||||
expect(deduplicated.static).toEqual(["User is allergic to peanuts"])
|
||||
expect(deduplicated.dynamic).toEqual(["User is shipping a release today"])
|
||||
expect(deduplicated.searchResults).toEqual(["User prefers async/await"])
|
||||
})
|
||||
|
||||
it("deduplicates search results against the profile in profile mode", () => {
|
||||
const deduplicated = deduplicateMemoriesForMode("profile", {
|
||||
static: [{ memory: "User is allergic to peanuts" }],
|
||||
dynamic: [],
|
||||
searchResults: [{ memory: "User is allergic to peanuts" }],
|
||||
})
|
||||
|
||||
expect(deduplicated.static).toEqual(["User is allergic to peanuts"])
|
||||
expect(deduplicated.searchResults).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
* Shared constants and descriptions for Supermemory tools
|
||||
*/
|
||||
|
||||
import type { MemoryMode } from "./shared/types"
|
||||
|
||||
// Tool descriptions
|
||||
export const TOOL_DESCRIPTIONS = {
|
||||
searchMemories:
|
||||
|
|
@ -177,3 +179,29 @@ export function deduplicateMemories(
|
|||
searchResults: searchMemories,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicates memory items against only the sources the given mode actually
|
||||
* injects into the prompt.
|
||||
*
|
||||
* `"query"` mode injects the search results but not the profile, so search
|
||||
* results must not be deduplicated against the profile: a memory present in
|
||||
* both would be dropped as a duplicate of something the model never sees, and
|
||||
* would disappear from the prompt entirely.
|
||||
*
|
||||
* @param mode - The memory retrieval mode
|
||||
* @param data - Profile data with memory items from different sources
|
||||
* @returns Deduplicated memory strings for each source
|
||||
*/
|
||||
export function deduplicateMemoriesForMode(
|
||||
mode: MemoryMode,
|
||||
data: ProfileWithMemories,
|
||||
): DeduplicatedMemories {
|
||||
const injectsProfile = mode !== "query"
|
||||
|
||||
return deduplicateMemories({
|
||||
static: injectsProfile ? data.static : [],
|
||||
dynamic: injectsProfile ? data.dynamic : [],
|
||||
searchResults: data.searchResults,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue