mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 03:03:24 -04:00
790 lines
24 KiB
TypeScript
790 lines
24 KiB
TypeScript
/**
|
|
* Tests for extractor.ts — Extraction Logic.
|
|
*
|
|
* Tests exported functions: extractEntities(), extractUserMessages(), runBackgroundExtraction().
|
|
* Note: validateExtractionResult() is not exported; it is tested indirectly through extractEntities().
|
|
* Note: passesAttentionGate() is defined in index.ts and not exported; cannot be tested directly.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import type { ExtractionConfig } from "./config.js";
|
|
import { extractUserMessages, extractEntities, runBackgroundExtraction } from "./extractor.js";
|
|
|
|
// ============================================================================
|
|
// extractUserMessages()
|
|
// ============================================================================
|
|
|
|
describe("extractUserMessages", () => {
|
|
it("should extract string content from user messages", () => {
|
|
const messages = [
|
|
{ role: "user", content: "I prefer TypeScript over JavaScript" },
|
|
{ role: "user", content: "My favorite color is blue" },
|
|
];
|
|
const result = extractUserMessages(messages);
|
|
expect(result).toEqual(["I prefer TypeScript over JavaScript", "My favorite color is blue"]);
|
|
});
|
|
|
|
it("should extract text from content block arrays", () => {
|
|
const messages = [
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "Hello, this is a content block message" },
|
|
{ type: "image", url: "http://example.com/img.png" },
|
|
{ type: "text", text: "Another text block in same message" },
|
|
],
|
|
},
|
|
];
|
|
const result = extractUserMessages(messages);
|
|
expect(result).toEqual([
|
|
"Hello, this is a content block message",
|
|
"Another text block in same message",
|
|
]);
|
|
});
|
|
|
|
it("should filter out assistant messages", () => {
|
|
const messages = [
|
|
{ role: "user", content: "This is a user message that is long enough" },
|
|
{ role: "assistant", content: "This is an assistant message" },
|
|
];
|
|
const result = extractUserMessages(messages);
|
|
expect(result).toEqual(["This is a user message that is long enough"]);
|
|
});
|
|
|
|
it("should filter out system messages", () => {
|
|
const messages = [
|
|
{ role: "system", content: "You are a helpful assistant with context" },
|
|
{ role: "user", content: "This is a user message that is long enough" },
|
|
];
|
|
const result = extractUserMessages(messages);
|
|
expect(result).toEqual(["This is a user message that is long enough"]);
|
|
});
|
|
|
|
it("should filter out messages shorter than 10 characters", () => {
|
|
const messages = [
|
|
{ role: "user", content: "short" }, // 5 chars
|
|
{ role: "user", content: "1234567890" }, // exactly 10 chars
|
|
{ role: "user", content: "This is longer than ten characters" },
|
|
];
|
|
const result = extractUserMessages(messages);
|
|
expect(result).toEqual(["1234567890", "This is longer than ten characters"]);
|
|
});
|
|
|
|
it("should strip <relevant-memories> blocks and keep user content", () => {
|
|
const messages = [
|
|
{ role: "user", content: "Normal user message that is long enough here" },
|
|
{
|
|
role: "user",
|
|
content:
|
|
"<relevant-memories>Some injected context</relevant-memories>\n\nWhat does Tarun prefer for meetings?",
|
|
},
|
|
];
|
|
const result = extractUserMessages(messages);
|
|
expect(result).toEqual([
|
|
"Normal user message that is long enough here",
|
|
"What does Tarun prefer for meetings?",
|
|
]);
|
|
});
|
|
|
|
it("should drop message if only injected context remains after stripping", () => {
|
|
const messages = [
|
|
{
|
|
role: "user",
|
|
content:
|
|
"<relevant-memories>Some injected context that should be ignored</relevant-memories>",
|
|
},
|
|
];
|
|
const result = extractUserMessages(messages);
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("should strip <system> blocks and keep user content", () => {
|
|
const messages = [
|
|
{
|
|
role: "user",
|
|
content: "<system>System markup</system>\n\nNormal user message that is long enough here",
|
|
},
|
|
];
|
|
const result = extractUserMessages(messages);
|
|
expect(result).toEqual(["Normal user message that is long enough here"]);
|
|
});
|
|
|
|
it("should strip <core-memory-refresh> blocks and keep user content", () => {
|
|
const messages = [
|
|
{
|
|
role: "user",
|
|
content:
|
|
"<core-memory-refresh>refreshed memories</core-memory-refresh>\n\nTell me about the project status",
|
|
},
|
|
];
|
|
const result = extractUserMessages(messages);
|
|
expect(result).toEqual(["Tell me about the project status"]);
|
|
});
|
|
|
|
it("should handle null and non-object messages gracefully", () => {
|
|
const messages = [
|
|
null,
|
|
undefined,
|
|
"not an object",
|
|
42,
|
|
{ role: "user", content: "Valid message with enough length" },
|
|
];
|
|
const result = extractUserMessages(messages as unknown[]);
|
|
expect(result).toEqual(["Valid message with enough length"]);
|
|
});
|
|
|
|
it("should return empty array when no user messages exist", () => {
|
|
const messages = [{ role: "assistant", content: "Only assistant messages" }];
|
|
const result = extractUserMessages(messages);
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("should return empty array for empty input", () => {
|
|
expect(extractUserMessages([])).toEqual([]);
|
|
});
|
|
|
|
it("should handle messages where content is neither string nor array", () => {
|
|
const messages = [
|
|
{ role: "user", content: 42 },
|
|
{ role: "user", content: null },
|
|
{ role: "user", content: { nested: true } },
|
|
];
|
|
const result = extractUserMessages(messages as unknown[]);
|
|
expect(result).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// extractEntities() — tests validateExtractionResult() indirectly
|
|
// ============================================================================
|
|
|
|
describe("extractEntities", () => {
|
|
// We need to mock `fetch` since callOpenRouter uses global fetch
|
|
const originalFetch = globalThis.fetch;
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
const enabledConfig: ExtractionConfig = {
|
|
enabled: true,
|
|
apiKey: "test-key",
|
|
model: "test-model",
|
|
baseUrl: "https://test.ai/api/v1",
|
|
temperature: 0.0,
|
|
maxRetries: 0, // No retries in tests
|
|
};
|
|
|
|
const disabledConfig: ExtractionConfig = {
|
|
...enabledConfig,
|
|
enabled: false,
|
|
};
|
|
|
|
function mockFetchResponse(content: string, status = 200) {
|
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
text: () => Promise.resolve(content),
|
|
json: () =>
|
|
Promise.resolve({
|
|
choices: [{ message: { content } }],
|
|
}),
|
|
});
|
|
}
|
|
|
|
it("should return null result when extraction is disabled", async () => {
|
|
const { result, transientFailure } = await extractEntities("test text", disabledConfig);
|
|
expect(result).toBeNull();
|
|
expect(transientFailure).toBe(false);
|
|
});
|
|
|
|
it("should extract valid entities from LLM response", async () => {
|
|
mockFetchResponse(
|
|
JSON.stringify({
|
|
category: "fact",
|
|
entities: [
|
|
{ name: "Tarun", type: "person", aliases: ["boss"], description: "The CEO" },
|
|
{ name: "Abundent", type: "organization" },
|
|
],
|
|
relationships: [
|
|
{ source: "Tarun", target: "Abundent", type: "WORKS_AT", confidence: 0.95 },
|
|
],
|
|
tags: [{ name: "Leadership", category: "business" }],
|
|
}),
|
|
);
|
|
|
|
const { result } = await extractEntities("Tarun works at Abundent", enabledConfig);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.category).toBe("fact");
|
|
|
|
// Entities should be normalized to lowercase
|
|
expect(result!.entities).toHaveLength(2);
|
|
expect(result!.entities[0].name).toBe("tarun");
|
|
expect(result!.entities[0].type).toBe("person");
|
|
expect(result!.entities[0].aliases).toEqual(["boss"]);
|
|
expect(result!.entities[0].description).toBe("The CEO");
|
|
expect(result!.entities[1].name).toBe("abundent");
|
|
expect(result!.entities[1].type).toBe("organization");
|
|
|
|
// Relationships should be normalized to lowercase source/target
|
|
expect(result!.relationships).toHaveLength(1);
|
|
expect(result!.relationships[0].source).toBe("tarun");
|
|
expect(result!.relationships[0].target).toBe("abundent");
|
|
expect(result!.relationships[0].type).toBe("WORKS_AT");
|
|
expect(result!.relationships[0].confidence).toBe(0.95);
|
|
|
|
// Tags should be normalized to lowercase
|
|
expect(result!.tags).toHaveLength(1);
|
|
expect(result!.tags[0].name).toBe("leadership");
|
|
expect(result!.tags[0].category).toBe("business");
|
|
});
|
|
|
|
it("should handle empty extraction result", async () => {
|
|
mockFetchResponse(
|
|
JSON.stringify({
|
|
category: "other",
|
|
entities: [],
|
|
relationships: [],
|
|
tags: [],
|
|
}),
|
|
);
|
|
|
|
const { result } = await extractEntities("just a greeting", enabledConfig);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.entities).toEqual([]);
|
|
expect(result!.relationships).toEqual([]);
|
|
expect(result!.tags).toEqual([]);
|
|
});
|
|
|
|
it("should handle missing fields in LLM response", async () => {
|
|
mockFetchResponse(
|
|
JSON.stringify({
|
|
// No category, entities, relationships, or tags
|
|
}),
|
|
);
|
|
|
|
const { result } = await extractEntities("some text", enabledConfig);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.category).toBeUndefined();
|
|
expect(result!.entities).toEqual([]);
|
|
expect(result!.relationships).toEqual([]);
|
|
expect(result!.tags).toEqual([]);
|
|
});
|
|
|
|
it("should filter out invalid entity types (fallback to concept)", async () => {
|
|
mockFetchResponse(
|
|
JSON.stringify({
|
|
entities: [
|
|
{ name: "Widget", type: "gadget" }, // invalid type -> concept
|
|
{ name: "Paris", type: "location" }, // valid type
|
|
],
|
|
relationships: [],
|
|
tags: [],
|
|
}),
|
|
);
|
|
|
|
const { result } = await extractEntities("test", enabledConfig);
|
|
expect(result!.entities).toHaveLength(2);
|
|
expect(result!.entities[0].type).toBe("concept"); // invalid type falls back to concept
|
|
expect(result!.entities[1].type).toBe("location");
|
|
});
|
|
|
|
it("should filter out invalid relationship types", async () => {
|
|
mockFetchResponse(
|
|
JSON.stringify({
|
|
entities: [],
|
|
relationships: [
|
|
{ source: "a", target: "b", type: "WORKS_AT", confidence: 0.9 }, // valid
|
|
{ source: "a", target: "b", type: "HATES", confidence: 0.9 }, // invalid type
|
|
],
|
|
tags: [],
|
|
}),
|
|
);
|
|
|
|
const { result } = await extractEntities("test", enabledConfig);
|
|
expect(result!.relationships).toHaveLength(1);
|
|
expect(result!.relationships[0].type).toBe("WORKS_AT");
|
|
});
|
|
|
|
it("should clamp confidence to 0-1 range", async () => {
|
|
mockFetchResponse(
|
|
JSON.stringify({
|
|
entities: [],
|
|
relationships: [
|
|
{ source: "a", target: "b", type: "KNOWS", confidence: 1.5 }, // over 1
|
|
{ source: "c", target: "d", type: "KNOWS", confidence: -0.5 }, // under 0
|
|
],
|
|
tags: [],
|
|
}),
|
|
);
|
|
|
|
const { result } = await extractEntities("test", enabledConfig);
|
|
expect(result!.relationships[0].confidence).toBe(1);
|
|
expect(result!.relationships[1].confidence).toBe(0);
|
|
});
|
|
|
|
it("should default confidence to 0.7 when not a number", async () => {
|
|
mockFetchResponse(
|
|
JSON.stringify({
|
|
entities: [],
|
|
relationships: [{ source: "a", target: "b", type: "KNOWS", confidence: "high" }],
|
|
tags: [],
|
|
}),
|
|
);
|
|
|
|
const { result } = await extractEntities("test", enabledConfig);
|
|
expect(result!.relationships[0].confidence).toBe(0.7);
|
|
});
|
|
|
|
it("should filter out entities without name", async () => {
|
|
mockFetchResponse(
|
|
JSON.stringify({
|
|
entities: [
|
|
{ name: "", type: "person" }, // empty name -> filtered
|
|
{ name: " ", type: "person" }, // whitespace-only name -> filtered (after trim)
|
|
{ name: "valid", type: "person" }, // valid
|
|
],
|
|
relationships: [],
|
|
tags: [],
|
|
}),
|
|
);
|
|
|
|
const { result } = await extractEntities("test", enabledConfig);
|
|
expect(result!.entities).toHaveLength(1);
|
|
expect(result!.entities[0].name).toBe("valid");
|
|
});
|
|
|
|
it("should filter out entities with non-object shape", async () => {
|
|
mockFetchResponse(
|
|
JSON.stringify({
|
|
entities: [null, "not an entity", 42, { name: "valid", type: "person" }],
|
|
relationships: [],
|
|
tags: [],
|
|
}),
|
|
);
|
|
|
|
const { result } = await extractEntities("test", enabledConfig);
|
|
expect(result!.entities).toHaveLength(1);
|
|
});
|
|
|
|
it("should filter out entities missing required fields", async () => {
|
|
mockFetchResponse(
|
|
JSON.stringify({
|
|
entities: [
|
|
{ type: "person" }, // missing name
|
|
{ name: "test" }, // missing type
|
|
{ name: "valid", type: "person" }, // has both
|
|
],
|
|
relationships: [],
|
|
tags: [],
|
|
}),
|
|
);
|
|
|
|
const { result } = await extractEntities("test", enabledConfig);
|
|
expect(result!.entities).toHaveLength(1);
|
|
expect(result!.entities[0].name).toBe("valid");
|
|
});
|
|
|
|
it("should default tag category to 'topic' when missing", async () => {
|
|
mockFetchResponse(
|
|
JSON.stringify({
|
|
entities: [],
|
|
relationships: [],
|
|
tags: [{ name: "neo4j" }], // no category
|
|
}),
|
|
);
|
|
|
|
const { result } = await extractEntities("test", enabledConfig);
|
|
expect(result!.tags[0].category).toBe("topic");
|
|
});
|
|
|
|
it("should filter out tags with empty names", async () => {
|
|
mockFetchResponse(
|
|
JSON.stringify({
|
|
entities: [],
|
|
relationships: [],
|
|
tags: [
|
|
{ name: "", category: "tech" }, // empty -> filtered
|
|
{ name: " ", category: "tech" }, // whitespace-only -> filtered
|
|
{ name: "valid", category: "tech" },
|
|
],
|
|
}),
|
|
);
|
|
|
|
const { result } = await extractEntities("test", enabledConfig);
|
|
expect(result!.tags).toHaveLength(1);
|
|
expect(result!.tags[0].name).toBe("valid");
|
|
});
|
|
|
|
it("should reject invalid category values", async () => {
|
|
mockFetchResponse(
|
|
JSON.stringify({
|
|
category: "invalid-category",
|
|
entities: [],
|
|
relationships: [],
|
|
tags: [],
|
|
}),
|
|
);
|
|
|
|
const { result } = await extractEntities("test", enabledConfig);
|
|
expect(result!.category).toBeUndefined();
|
|
});
|
|
|
|
it("should accept valid category values", async () => {
|
|
for (const category of ["preference", "fact", "decision", "entity", "other"]) {
|
|
mockFetchResponse(
|
|
JSON.stringify({
|
|
category,
|
|
entities: [],
|
|
relationships: [],
|
|
tags: [],
|
|
}),
|
|
);
|
|
const { result } = await extractEntities(`test ${category}`, enabledConfig);
|
|
expect(result!.category).toBe(category);
|
|
}
|
|
});
|
|
|
|
it("should return null result for malformed JSON response (permanent failure)", async () => {
|
|
mockFetchResponse("not valid json at all");
|
|
|
|
const { result, transientFailure } = await extractEntities("test", enabledConfig);
|
|
// callOpenRouter returns the raw string, JSON.parse fails, catch returns null
|
|
expect(result).toBeNull();
|
|
expect(transientFailure).toBe(false);
|
|
});
|
|
|
|
it("should return null result when API returns error status", async () => {
|
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 500,
|
|
text: () => Promise.resolve("Internal Server Error"),
|
|
});
|
|
|
|
const { result } = await extractEntities("test", enabledConfig);
|
|
// API error 500 is not in the transient list (only 429, 502, 503, 504)
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("should return null result when API returns no content", async () => {
|
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ choices: [{ message: { content: null } }] }),
|
|
});
|
|
|
|
const { result, transientFailure } = await extractEntities("test", enabledConfig);
|
|
expect(result).toBeNull();
|
|
expect(transientFailure).toBe(false);
|
|
});
|
|
|
|
it("should normalize alias strings to lowercase", async () => {
|
|
mockFetchResponse(
|
|
JSON.stringify({
|
|
entities: [{ name: "John", type: "person", aliases: ["Johnny", "JOHN", "j.doe"] }],
|
|
relationships: [],
|
|
tags: [],
|
|
}),
|
|
);
|
|
|
|
const { result } = await extractEntities("test", enabledConfig);
|
|
expect(result!.entities[0].aliases).toEqual(["johnny", "john", "j.doe"]);
|
|
});
|
|
|
|
it("should filter out non-string aliases", async () => {
|
|
mockFetchResponse(
|
|
JSON.stringify({
|
|
entities: [{ name: "John", type: "person", aliases: ["valid", 42, null, "also-valid"] }],
|
|
relationships: [],
|
|
tags: [],
|
|
}),
|
|
);
|
|
|
|
const { result } = await extractEntities("test", enabledConfig);
|
|
expect(result!.entities[0].aliases).toEqual(["valid", "also-valid"]);
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// runBackgroundExtraction()
|
|
// ============================================================================
|
|
|
|
describe("runBackgroundExtraction", () => {
|
|
const originalFetch = globalThis.fetch;
|
|
|
|
let mockLogger: {
|
|
info: ReturnType<typeof vi.fn>;
|
|
warn: ReturnType<typeof vi.fn>;
|
|
error: ReturnType<typeof vi.fn>;
|
|
debug: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
let mockDb: {
|
|
updateExtractionStatus: ReturnType<typeof vi.fn>;
|
|
mergeEntity: ReturnType<typeof vi.fn>;
|
|
createMentions: ReturnType<typeof vi.fn>;
|
|
createEntityRelationship: ReturnType<typeof vi.fn>;
|
|
tagMemory: ReturnType<typeof vi.fn>;
|
|
updateMemoryCategory: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
let mockEmbeddings: {
|
|
embed: ReturnType<typeof vi.fn>;
|
|
embedBatch: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
mockLogger = {
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
};
|
|
mockDb = {
|
|
updateExtractionStatus: vi.fn().mockResolvedValue(undefined),
|
|
mergeEntity: vi.fn().mockResolvedValue(undefined),
|
|
createMentions: vi.fn().mockResolvedValue(undefined),
|
|
createEntityRelationship: vi.fn().mockResolvedValue(undefined),
|
|
tagMemory: vi.fn().mockResolvedValue(undefined),
|
|
updateMemoryCategory: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
mockEmbeddings = {
|
|
embed: vi.fn().mockResolvedValue([0.1, 0.2, 0.3]),
|
|
embedBatch: vi.fn().mockResolvedValue([[0.1, 0.2, 0.3]]),
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
const enabledConfig: ExtractionConfig = {
|
|
enabled: true,
|
|
apiKey: "test-key",
|
|
model: "test-model",
|
|
baseUrl: "https://test.ai/api/v1",
|
|
temperature: 0.0,
|
|
maxRetries: 0,
|
|
};
|
|
|
|
const disabledConfig: ExtractionConfig = {
|
|
...enabledConfig,
|
|
enabled: false,
|
|
};
|
|
|
|
function mockFetchResponse(content: string) {
|
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: () =>
|
|
Promise.resolve({
|
|
choices: [{ message: { content } }],
|
|
}),
|
|
});
|
|
}
|
|
|
|
it("should skip extraction and mark as 'skipped' when disabled", async () => {
|
|
await runBackgroundExtraction(
|
|
"mem-1",
|
|
"test text",
|
|
mockDb as never,
|
|
mockEmbeddings as never,
|
|
disabledConfig,
|
|
mockLogger,
|
|
);
|
|
expect(mockDb.updateExtractionStatus).toHaveBeenCalledWith("mem-1", "skipped");
|
|
});
|
|
|
|
it("should mark as 'failed' when extraction returns null", async () => {
|
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 500,
|
|
text: () => Promise.resolve("error"),
|
|
});
|
|
|
|
await runBackgroundExtraction(
|
|
"mem-1",
|
|
"test text",
|
|
mockDb as never,
|
|
mockEmbeddings as never,
|
|
enabledConfig,
|
|
mockLogger,
|
|
);
|
|
expect(mockDb.updateExtractionStatus).toHaveBeenCalledWith("mem-1", "failed");
|
|
});
|
|
|
|
it("should mark as 'complete' when extraction result is empty", async () => {
|
|
mockFetchResponse(
|
|
JSON.stringify({
|
|
entities: [],
|
|
relationships: [],
|
|
tags: [],
|
|
}),
|
|
);
|
|
|
|
await runBackgroundExtraction(
|
|
"mem-1",
|
|
"test text",
|
|
mockDb as never,
|
|
mockEmbeddings as never,
|
|
enabledConfig,
|
|
mockLogger,
|
|
);
|
|
expect(mockDb.updateExtractionStatus).toHaveBeenCalledWith("mem-1", "complete");
|
|
});
|
|
|
|
it("should merge entities, create mentions, and mark complete", async () => {
|
|
mockFetchResponse(
|
|
JSON.stringify({
|
|
category: "fact",
|
|
entities: [{ name: "Alice", type: "person" }],
|
|
relationships: [],
|
|
tags: [],
|
|
}),
|
|
);
|
|
|
|
await runBackgroundExtraction(
|
|
"mem-1",
|
|
"Alice is a developer",
|
|
mockDb as never,
|
|
mockEmbeddings as never,
|
|
enabledConfig,
|
|
mockLogger,
|
|
);
|
|
|
|
expect(mockDb.mergeEntity).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: "alice",
|
|
type: "person",
|
|
}),
|
|
);
|
|
expect(mockDb.createMentions).toHaveBeenCalledWith("mem-1", "alice", "context", 1.0);
|
|
expect(mockDb.updateMemoryCategory).toHaveBeenCalledWith("mem-1", "fact");
|
|
expect(mockDb.updateExtractionStatus).toHaveBeenCalledWith("mem-1", "complete");
|
|
});
|
|
|
|
it("should create entity relationships", async () => {
|
|
mockFetchResponse(
|
|
JSON.stringify({
|
|
entities: [
|
|
{ name: "Alice", type: "person" },
|
|
{ name: "Acme", type: "organization" },
|
|
],
|
|
relationships: [{ source: "Alice", target: "Acme", type: "WORKS_AT", confidence: 0.9 }],
|
|
tags: [],
|
|
}),
|
|
);
|
|
|
|
await runBackgroundExtraction(
|
|
"mem-1",
|
|
"Alice works at Acme",
|
|
mockDb as never,
|
|
mockEmbeddings as never,
|
|
enabledConfig,
|
|
mockLogger,
|
|
);
|
|
|
|
expect(mockDb.createEntityRelationship).toHaveBeenCalledWith("alice", "acme", "WORKS_AT", 0.9);
|
|
});
|
|
|
|
it("should tag memories", async () => {
|
|
mockFetchResponse(
|
|
JSON.stringify({
|
|
entities: [],
|
|
relationships: [],
|
|
tags: [{ name: "Programming", category: "tech" }],
|
|
}),
|
|
);
|
|
|
|
await runBackgroundExtraction(
|
|
"mem-1",
|
|
"test text",
|
|
mockDb as never,
|
|
mockEmbeddings as never,
|
|
enabledConfig,
|
|
mockLogger,
|
|
);
|
|
|
|
expect(mockDb.tagMemory).toHaveBeenCalledWith("mem-1", "programming", "tech");
|
|
});
|
|
|
|
it("should not update category when result has no category", async () => {
|
|
mockFetchResponse(
|
|
JSON.stringify({
|
|
entities: [{ name: "Test", type: "concept" }],
|
|
relationships: [],
|
|
tags: [],
|
|
}),
|
|
);
|
|
|
|
await runBackgroundExtraction(
|
|
"mem-1",
|
|
"test",
|
|
mockDb as never,
|
|
mockEmbeddings as never,
|
|
enabledConfig,
|
|
mockLogger,
|
|
);
|
|
|
|
expect(mockDb.updateMemoryCategory).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should handle entity merge failure gracefully", async () => {
|
|
mockFetchResponse(
|
|
JSON.stringify({
|
|
entities: [
|
|
{ name: "Alice", type: "person" },
|
|
{ name: "Bob", type: "person" },
|
|
],
|
|
relationships: [],
|
|
tags: [],
|
|
}),
|
|
);
|
|
|
|
// First entity merge fails, second succeeds
|
|
mockDb.mergeEntity.mockRejectedValueOnce(new Error("merge failed"));
|
|
mockDb.mergeEntity.mockResolvedValueOnce(undefined);
|
|
|
|
await runBackgroundExtraction(
|
|
"mem-1",
|
|
"Alice and Bob",
|
|
mockDb as never,
|
|
mockEmbeddings as never,
|
|
enabledConfig,
|
|
mockLogger,
|
|
);
|
|
|
|
// Should still continue and complete
|
|
expect(mockDb.mergeEntity).toHaveBeenCalledTimes(2);
|
|
expect(mockDb.updateExtractionStatus).toHaveBeenCalledWith("mem-1", "complete");
|
|
expect(mockLogger.warn).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should log extraction results", async () => {
|
|
mockFetchResponse(
|
|
JSON.stringify({
|
|
category: "fact",
|
|
entities: [{ name: "Test", type: "concept" }],
|
|
relationships: [{ source: "a", target: "b", type: "RELATED_TO", confidence: 0.8 }],
|
|
tags: [{ name: "tech" }],
|
|
}),
|
|
);
|
|
|
|
await runBackgroundExtraction(
|
|
"mem-12345678-abcd",
|
|
"test",
|
|
mockDb as never,
|
|
mockEmbeddings as never,
|
|
enabledConfig,
|
|
mockLogger,
|
|
);
|
|
|
|
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining("extraction complete"));
|
|
});
|
|
});
|