Files
memind/conversation-memory.test.mjs
T
john 57d2c14fca
Memind CI / Test, build, and release guards (push) Successful in 2m55s
fix(memory): honor empty extraction results
2026-07-22 11:43:45 +08:00

426 lines
14 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { createConversationMemoryService, extractConversationMessageText } from './conversation-memory.mjs';
import { encryptSecret } from './llm-providers.mjs';
function createPool({ provider = null } = {}) {
const state = {
messages: [],
memories: [],
analyzed: new Set(),
};
return {
state,
async query(sql, params = []) {
if (sql.includes('INSERT INTO h5_conversation_messages')) {
for (const row of params[0]) {
const [
id,
userId,
sessionId,
messageKey,
sequenceNo,
role,
text,
rawJson,
createdAt,
updatedAt,
] = row;
const existing = state.messages.find(
(item) => item.agent_session_id === sessionId && item.message_key === messageKey,
);
const next = {
id,
user_id: userId,
agent_session_id: sessionId,
message_key: messageKey,
sequence_no: sequenceNo,
role,
text,
raw_json: rawJson,
created_at: createdAt,
updated_at: updatedAt,
analyzed_at: existing?.analyzed_at ?? null,
};
if (existing) Object.assign(existing, next);
else state.messages.push(next);
}
return [{ affectedRows: params[0].length }];
}
if (sql.includes('FROM h5_conversation_messages')) {
const [userId, limit] = params;
return [
state.messages
.filter((item) => item.user_id === userId && item.role === 'user' && item.analyzed_at == null)
.slice(0, limit),
];
}
if (sql.includes('INSERT INTO h5_user_memory_items')) {
for (const row of params[0]) {
const [
id,
userId,
label,
memoryHash,
memoryText,
evidenceMessageId,
sourceSessionId,
confidence,
rawJson,
createdAt,
updatedAt,
] = row;
state.memories.push({
id,
user_id: userId,
label,
memory_hash: memoryHash,
memory_text: memoryText,
evidence_message_id: evidenceMessageId,
source_session_id: sourceSessionId,
confidence,
raw_json: rawJson,
created_at: createdAt,
updated_at: updatedAt,
});
}
return [{ affectedRows: params[0].length }];
}
if (sql.includes('UPDATE h5_conversation_messages SET analyzed_at')) {
const [analyzedAt, ids] = params;
for (const item of state.messages) {
if (ids.includes(item.id)) item.analyzed_at = analyzedAt;
}
return [{ affectedRows: ids.length }];
}
if (sql.includes('FROM h5_user_memory_items')) {
const [userId, limit] = params;
return [
state.memories
.filter((item) => item.user_id === userId)
.slice(0, limit)
.map((item) => ({ ...item, status: 'active' })),
];
}
if (sql.includes('FROM h5_llm_provider_keys')) return [[provider].filter(Boolean)];
throw new Error(`Unexpected SQL: ${sql}`);
},
};
}
test('extractConversationMessageText reads text content and display text', () => {
assert.equal(
extractConversationMessageText({ content: [{ type: 'text', text: ' hello ' }] }),
'hello',
);
assert.equal(
extractConversationMessageText({ content: [], metadata: { displayText: 'fallback' } }),
'fallback',
);
});
test('saveAndAnalyze stores messages and fallback memories', async () => {
const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '0';
const pool = createPool();
const service = createConversationMemoryService(pool, { now: () => 1000 });
const result = await service.saveAndAnalyze('session-1', 'user-1', [
{
id: 'm1',
role: 'user',
content: [{ type: 'text', text: '我喜欢简洁直接的回答,也关注 AI 产品设计。' }],
metadata: { userVisible: true },
},
{
id: 'm2',
role: 'assistant',
content: [{ type: 'text', text: '好的。' }],
metadata: { userVisible: true },
},
]);
assert.equal(result.saved, 2);
assert.equal(result.analyzed, 1);
assert.equal(result.memories, 1);
assert.equal(pool.state.messages.length, 2);
assert.equal(pool.state.memories[0].label, 'preference');
assert.match(pool.state.memories[0].memory_text, /简洁直接/);
assert.equal(pool.state.messages.find((item) => item.message_key === 'm1')?.analyzed_at, 1000);
if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous;
});
test('saveAndAnalyze re-analyzes saved session messages even when already marked analyzed', async () => {
const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '0';
const pool = createPool();
const service = createConversationMemoryService(pool, { now: () => 1500 });
const messages = [{
id: 'm-remember',
role: 'user',
content: [{ type: 'text', text: '请记住:我的测试别名是蓝鲸42。只回复已记住。' }],
metadata: { userVisible: true },
}];
const first = await service.saveAndAnalyze('session-remember', 'user-remember', messages);
assert.equal(first.analyzed, 1);
assert.equal(first.memories, 1);
assert.match(pool.state.memories.at(-1).memory_text, /蓝鲸42/);
pool.state.messages[0].analyzed_at = 1500;
const second = await service.saveAndAnalyze('session-remember', 'user-remember', messages);
assert.equal(second.analyzed, 1);
assert.equal(second.memories, 1);
if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous;
});
test('saveAndAnalyze marks messages analyzed when llm extraction fails and no memory is stored', async () => {
const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '1';
const pool = createPool();
const service = createConversationMemoryService(pool, {
now: () => 2000,
llmProviderService: {
async createChatCompletion() {
throw new Error('upstream unavailable');
},
},
});
const result = await service.saveAndAnalyze('session-2', 'user-2', [
{
id: 'm3',
role: 'user',
content: [{ type: 'text', text: '今天下雨了。' }],
metadata: { userVisible: true },
},
]);
assert.equal(result.saved, 1);
assert.equal(result.memories, 0);
assert.equal(pool.state.messages.find((item) => item.message_key === 'm3')?.analyzed_at, 2000);
if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous;
});
test('saveAndAnalyze still uses fallback when llm extraction is unavailable', async () => {
const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '1';
const pool = createPool();
const service = createConversationMemoryService(pool, {
now: () => 2250,
llmProviderService: {
async createChatCompletion() {
throw new Error('upstream unavailable');
},
},
});
const result = await service.saveAndAnalyze('session-fallback', 'user-fallback', [
{
id: 'm-fallback',
role: 'user',
content: [{ type: 'text', text: '我喜欢简洁的回答。' }],
metadata: { userVisible: true },
},
]);
assert.equal(result.memories, 1);
assert.match(pool.state.memories[0].memory_text, /简洁/);
if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous;
});
test('saveAndAnalyze uses admin effective env for memory extraction model', async () => {
const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '1';
const pool = createPool();
const llmCalls = [];
const service = createConversationMemoryService(pool, {
now: () => 2600,
getEffectiveEnv: async () => ({
MEMIND_CHAT_ROUTER_MODEL_PROVIDER_KEY_ID: 'router-key',
MEMIND_CHAT_ROUTER_MODEL: 'deepseek-chat',
}),
llmProviderService: {
async createChatCompletion({ providerKeyId, model, messages }) {
llmCalls.push({ providerKeyId, model, messages });
return {
ok: true,
reply: JSON.stringify({
memories: [{ label: 'fact', text: '用户叫 John', confidence: 0.9 }],
}),
};
},
},
});
await service.saveAndAnalyze('session-admin', 'user-admin', [
{
id: 'm-admin',
role: 'user',
content: [{ type: 'text', text: '我是 John。' }],
},
]);
assert.equal(llmCalls.length, 1);
assert.equal(llmCalls[0].providerKeyId, 'router-key');
assert.equal(llmCalls[0].model, 'deepseek-chat');
if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous;
});
test('saveAndAnalyze extracts memories through llmProviderService', async () => {
const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '1';
const pool = createPool();
const service = createConversationMemoryService(pool, {
now: () => 2500,
llmProviderService: {
async createChatCompletion({ messages }) {
assert.match(String(messages?.[0]?.content ?? ''), /长期记忆/);
return {
ok: true,
reply: JSON.stringify({
memories: [{ label: 'goal', text: '用户计划去日本旅游', confidence: 0.82 }],
}),
};
},
},
});
const result = await service.saveAndAnalyze('session-llm', 'user-llm', [
{
id: 'm-japan',
role: 'user',
content: [{ type: 'text', text: '我打算带家人去日本玩 5 天。' }],
metadata: { userVisible: true },
},
]);
assert.equal(result.memories, 1);
assert.equal(pool.state.memories[0].label, 'goal');
assert.match(pool.state.memories[0].memory_text, /日本/);
if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous;
});
test('saveAndAnalyze respects an empty llm result instead of storing a question through fallback', async () => {
const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '1';
const pool = createPool();
const service = createConversationMemoryService(pool, {
now: () => 2750,
llmProviderService: {
async createChatCompletion({ messages }) {
assert.match(String(messages?.[0]?.content ?? ''), /问句没有提供答案时不要记录/);
return {
ok: true,
reply: JSON.stringify({ memories: [] }),
};
},
},
});
const result = await service.saveAndAnalyze('session-question', 'user-question', [
{
id: 'm-question',
role: 'user',
content: [{ type: 'text', text: '用户记住的记忆召回灰度测试代号是什么?' }],
metadata: { userVisible: true },
},
]);
assert.equal(result.analyzed, 1);
assert.equal(result.memories, 0);
assert.equal(pool.state.memories.length, 0);
if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous;
});
test('saveAndAnalyze throttles repeated llm extraction warnings', async () => {
const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '1';
const warnings = [];
const originalWarn = console.warn;
console.warn = (...args) => warnings.push(args);
let currentNow = 4000;
try {
const secret = encryptSecret('test-key', 'memory-test-key');
const pool = createPool({
provider: {
api_url: 'https://llm.example.com/v1',
default_model: 'memory-model',
api_key_ciphertext: secret.ciphertext,
api_key_iv: secret.iv,
api_key_tag: secret.tag,
},
});
const service = createConversationMemoryService(pool, {
now: () => currentNow,
encryptionKey: 'memory-test-key',
llmWarningIntervalMs: 60000,
fetch: async () => {
throw new Error('upstream unavailable');
},
});
await service.saveAndAnalyze('session-warn-1', 'user-warn', [
{
id: 'warn-1',
role: 'user',
content: [{ type: 'text', text: '今天下雨了。' }],
metadata: { userVisible: true },
},
]);
currentNow += 1000;
await service.saveAndAnalyze('session-warn-2', 'user-warn', [
{
id: 'warn-2',
role: 'user',
content: [{ type: 'text', text: '明天可能也下雨。' }],
metadata: { userVisible: true },
},
]);
} finally {
console.warn = originalWarn;
if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous;
}
assert.equal(warnings.length, 1);
assert.match(String(warnings[0][0]), /conversation-memory/);
});
test('saveAndAnalyze still marks messages analyzed when llm extraction is disabled and fallback stores nothing', async () => {
const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '0';
const pool = createPool();
const service = createConversationMemoryService(pool, { now: () => 3000 });
const result = await service.saveAndAnalyze('session-3', 'user-3', [
{
id: 'm4',
role: 'user',
content: [{ type: 'text', text: '今天天气不错。' }],
metadata: { userVisible: true },
},
]);
assert.equal(result.saved, 1);
assert.equal(result.memories, 0);
assert.equal(pool.state.messages.find((item) => item.message_key === 'm4')?.analyzed_at, 3000);
if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous;
});