259 lines
8.5 KiB
JavaScript
259 lines
8.5 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 leaves messages unanalyzed when 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,
|
|
fetch: async () => {
|
|
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, null);
|
|
|
|
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;
|
|
});
|