Files
memind/episodic-memory.test.mjs
T
john 93d7bc0cd5
Memind CI / Test, build, and release guards (pull_request) Successful in 4m37s
feat: add episodic history recall
2026-07-22 12:36:20 +08:00

267 lines
10 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildEpisodicMemorySchemaSql,
buildEpisodicSnapshotDocument,
createEpisodicMemoryService,
extractEpisodicRecallTopic,
isEpisodicRecallQuery,
} from './episodic-memory.mjs';
import { createSessionSnapshotService } from './session-snapshot.mjs';
function visibleMessage(role, text, metadata = {}) {
return {
role,
content: [{ type: 'text', text }],
metadata: { userVisible: true, ...metadata },
};
}
test('episodic recall recognizes historical conversation questions and extracts the topic', () => {
assert.equal(isEpisodicRecallQuery('你还记得我们聊过德川家康吗?'), true);
assert.equal(extractEpisodicRecallTopic('你还记得我们聊过德川家康吗?'), '德川家康');
assert.equal(extractEpisodicRecallTopic('我们之前关于德川家康聊过吗?'), '德川家康');
assert.equal(extractEpisodicRecallTopic('我们上次聊过什么?'), '');
assert.equal(isEpisodicRecallQuery('上次我们聊了什么?'), true);
assert.equal(extractEpisodicRecallTopic('上次我们聊了什么?'), '');
assert.equal(isEpisodicRecallQuery('记住我喜欢简洁回答'), false);
});
test('episodic snapshot document keeps only bounded user-visible conversation text', () => {
const document = buildEpisodicSnapshotDocument({
sessionId: 'session-history',
userId: 'user-1',
session: {
display_title: '日本战国史',
updated_at: '2026-07-20T10:00:00.000Z',
},
messages: [
visibleMessage(
'user',
'【Memind 任务编排】这是内部路由\n用户任务:我们聊聊德川家康的生平',
),
visibleMessage('assistant', '德川家康建立了江户幕府。'),
visibleMessage('assistant', '我将调用 load_skill 处理内部流程。'),
visibleMessage('user', '这条不可见', { userVisible: false }),
{ role: 'tool', content: [{ type: 'text', text: 'secret tool output' }] },
],
});
assert.equal(document.sessionId, 'session-history');
assert.match(document.searchText, /我们聊聊德川家康的生平/);
assert.match(document.searchText, /建立了江户幕府/);
assert.doesNotMatch(document.searchText, /内部路由|load_skill|不可见|secret tool output/);
assert.equal(document.messageCount, 2);
});
test('episodic schema is additive and user scoped', () => {
const sql = buildEpisodicMemorySchemaSql();
assert.match(sql, /CREATE TABLE IF NOT EXISTS `h5_episodic_memory_items`/);
assert.match(sql, /user_id CHAR\(36\) NOT NULL/);
assert.match(sql, /idx_h5_episodic_user_updated/);
assert.match(sql, /ON DELETE CASCADE/);
});
test('episodic resolve searches the same user, excludes current session, and returns evidence', async () => {
const calls = [];
const pool = {
async query(sql, params = []) {
calls.push({ sql, params });
if (sql.includes('FROM `h5_episodic_memory_items`')) {
return [[{
agent_session_id: 'session-history',
user_id: 'user-1',
display_title: '日本战国史',
summary_text: '用户:德川家康有什么影响? 助手:他建立了江户幕府。',
search_text: '标题:日本战国史\n用户:德川家康有什么影响?\n助手:他建立了江户幕府。',
message_count: 2,
source_updated_at: '2026-07-20T10:00:00.000Z',
session_updated_at: Date.parse('2026-07-20T10:00:00.000Z'),
}]];
}
throw new Error(`unexpected query: ${sql}`);
},
};
const service = createEpisodicMemoryService(pool, {
env: {
MEMORY_RETRIEVER_EPISODIC_ENABLED: '1',
MEMORY_RETRIEVER_ENABLED: '1',
MEMORY_RETRIEVER_EPISODIC_MODE: 'active',
MEMORY_RETRIEVER_LIMIT: '3',
MEMORY_RETRIEVER_TIMEOUT_MS: '500',
},
});
const result = await service.resolve({
userId: 'user-1',
sessionId: 'session-current',
query: '你还记得我们聊过德川家康吗?',
});
assert.equal(result.skipped, false);
assert.equal(result.memories.length, 1);
assert.equal(result.memories[0].label, '历史会话');
assert.equal(result.memories[0].sessionId, 'session-history');
assert.deepEqual(result.memories[0].evidence.matchedTerms, ['德川家康']);
assert.match(result.memories[0].text, /2026-07-20/);
assert.match(result.memories[0].text, /江户幕府/);
assert.equal(calls.length, 1);
assert.equal(calls[0].params[0], 'user-1');
assert.equal(calls[0].params[1], 'session-current');
assert.match(calls[0].sql, /user_id = \?/);
assert.match(calls[0].sql, /agent_session_id <> \?/);
});
test('episodic resolve falls back to old session snapshots when the index is unavailable', async () => {
const calls = [];
const pool = {
async query(sql, params = []) {
calls.push({ sql, params });
if (sql.includes('FROM `h5_episodic_memory_items`')) {
throw new Error('table unavailable');
}
if (sql.includes('FROM h5_session_snapshots')) {
return [[{
agent_session_id: 'old-session',
user_id: 'user-1',
name: 'New Chat',
display_title: '历史人物',
updated_at_str: '2026-06-01T08:00:00.000Z',
source_updated_at: '2026-06-01T08:00:00.000Z',
synced_at: Date.parse('2026-06-01T08:00:00.000Z'),
messages_json: JSON.stringify([
visibleMessage('user', '我们之前讨论过德川家康。'),
visibleMessage('assistant', '重点是关原之战和江户幕府。'),
]),
}]];
}
if (sql.startsWith('INSERT INTO `h5_episodic_memory_items`')) return [{ affectedRows: 1 }];
throw new Error(`unexpected query: ${sql}`);
},
};
const warnings = [];
const service = createEpisodicMemoryService(pool, {
env: {
MEMORY_RETRIEVER_EPISODIC_ENABLED: '1',
MEMORY_RETRIEVER_ENABLED: '1',
MEMORY_RETRIEVER_EPISODIC_MODE: 'active',
MEMORY_RETRIEVER_TIMEOUT_MS: '500',
},
logger: { warn(message) { warnings.push(message); } },
});
const result = await service.resolve({
userId: 'user-1',
sessionId: 'current-session',
query: '我们之前聊过德川家康,你还记得吗?',
});
assert.equal(result.memories.length, 1);
assert.equal(result.memories[0].sessionId, 'old-session');
assert.equal(result.memories[0].source, 'session-snapshot-fallback');
assert.equal(result.degraded, true);
assert.ok(warnings.some((message) => message.includes('index query degraded')));
assert.ok(calls.some(({ sql }) => sql.includes('FROM h5_session_snapshots')));
});
test('episodic retrieval stays off for ordinary chat and when the feature flag is disabled', async () => {
let queries = 0;
const pool = { async query() { queries += 1; return [[]]; } };
const enabledService = createEpisodicMemoryService(pool, {
env: {
MEMORY_RETRIEVER_EPISODIC_ENABLED: '1',
MEMORY_RETRIEVER_ENABLED: '1',
MEMORY_RETRIEVER_EPISODIC_MODE: 'active',
},
});
const disabledService = createEpisodicMemoryService(pool, {
env: { MEMORY_RETRIEVER_EPISODIC_ENABLED: '0' },
});
assert.equal((await enabledService.resolve({ userId: 'u1', query: '今天过得怎么样?' })).reason, 'not_historical_recall');
assert.equal((await disabledService.resolve({ userId: 'u1', query: '我们之前聊过什么?' })).reason, 'disabled');
assert.equal(queries, 0);
});
test('episodic canary mode indexes and resolves only configured users', async () => {
const inserts = [];
const pool = {
async query(sql, params = []) {
if (sql.startsWith('INSERT INTO `h5_episodic_memory_items`')) {
inserts.push(params);
return [{ affectedRows: 1 }];
}
return [[]];
},
};
const service = createEpisodicMemoryService(pool, {
env: {
MEMORY_RETRIEVER_EPISODIC_ENABLED: '1',
MEMORY_RETRIEVER_ENABLED: '1',
MEMORY_RETRIEVER_EPISODIC_MODE: 'canary',
MEMORY_RETRIEVER_EPISODIC_CANARY_USER_IDS: 'user-canary,user-other',
},
});
const snapshot = {
sessionId: 'session-1',
session: { updated_at: '2026-07-22T10:00:00.000Z' },
messages: [visibleMessage('user', '我们聊过德川家康')],
};
const controlWrite = await service.upsertSnapshot({ ...snapshot, userId: 'user-control' });
const canaryWrite = await service.upsertSnapshot({ ...snapshot, userId: 'user-canary' });
const controlRead = await service.resolve({
userId: 'user-control',
query: '我们之前聊过德川家康吗?',
});
const status = await service.getStatus();
assert.equal(controlWrite.reason, 'outside_canary');
assert.equal(canaryWrite.ok, true);
assert.equal(inserts.length, 1);
assert.equal(inserts[0][1], 'user-canary');
assert.equal(controlRead.reason, 'outside_canary');
assert.equal(controlRead.mode, 'canary');
assert.equal(status.configuredEnabled, true);
assert.equal(status.mode, 'canary');
assert.equal(status.canaryUserCount, 2);
assert.equal(status.metrics.indexed, 1);
assert.equal(status.metrics.indexSkipped, 1);
});
test('session snapshot save and delete keep the episodic index synchronized', async () => {
const indexed = [];
const removed = [];
const pool = {
async query(sql) {
if (sql.includes('INSERT INTO h5_session_snapshots')) return [{ affectedRows: 1 }];
if (sql.includes('DELETE FROM h5_session_snapshots')) return [{ affectedRows: 1 }];
throw new Error(`unexpected query: ${sql}`);
},
};
const service = createSessionSnapshotService(pool, {
episodicMemoryService: {
async upsertSnapshot(input) { indexed.push(input); },
async remove(sessionId) { removed.push(sessionId); },
},
});
const messages = [visibleMessage('user', '聊聊德川家康')];
await service.save(
'session-1',
'user-1',
{
name: 'New Chat',
updated_at: '2026-07-22T10:00:00.000Z',
message_count: 1,
},
messages,
);
await service.remove('session-1');
assert.equal(indexed.length, 1);
assert.equal(indexed[0].sessionId, 'session-1');
assert.equal(indexed[0].session.display_title, '聊聊德川家康');
assert.deepEqual(removed, ['session-1']);
});