feat: add conversation memory pipeline
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import type { Fact } from 'identitydb';
|
||||
import {
|
||||
generateSchedule,
|
||||
getAvailabilitySnapshot,
|
||||
@@ -6,6 +7,8 @@ import {
|
||||
replyToConversation,
|
||||
setAvailabilityStatus,
|
||||
startConversation,
|
||||
type ConversationMemoryClassificationResult,
|
||||
type ConversationMemoryExtractionResult,
|
||||
type ConversationMemorySelectionResult,
|
||||
type ConversationTurnPlan,
|
||||
type ScheduleGenerationResult,
|
||||
@@ -118,6 +121,162 @@ describe('conversation APIs', () => {
|
||||
expect(history[0]?.proactive).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies inbound and outbound conversation messages, extracts only approved memories, and stores trace metadata', async () => {
|
||||
const db = await createDb();
|
||||
await seedPersonaMemory(db);
|
||||
const classifierPrompts: string[] = [];
|
||||
const extractorPrompts: string[] = [];
|
||||
|
||||
await replyToConversation(db, {
|
||||
spaceName: 'persona:minji',
|
||||
counterpartId: 'user:shinwoo',
|
||||
counterpartDisplayName: 'Shinwoo',
|
||||
message: '이번주말에 등산 가고 싶어',
|
||||
currentTime: '2026-05-12T12:00:00.000Z',
|
||||
mandatoryMemoryModel: createSelectionModel(['m1']),
|
||||
contextualMemoryModel: createSelectionModel([]),
|
||||
responseModel: createResponseModel({
|
||||
mode: 'reply',
|
||||
messages: ['좋다 나도 산 좋아해', '저녁엔가족이랑먹어'],
|
||||
}),
|
||||
rng: () => 0,
|
||||
memoryPipeline: {
|
||||
classifierModel: createMemoryClassifier([
|
||||
{ shouldRemember: true, reason: 'stores a durable user preference' },
|
||||
{ shouldRemember: false, reason: 'small talk only' },
|
||||
{ shouldRemember: true, reason: 'reveals a stable persona routine' },
|
||||
], classifierPrompts),
|
||||
extractorModel: createMemoryExtractor([
|
||||
{
|
||||
facts: [
|
||||
{
|
||||
domain: 'persona.relationship',
|
||||
statement: 'Shinwoo wants to go hiking on weekends.',
|
||||
topics: [{ name: 'Shinwoo' }, { name: 'hiking' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
facts: [
|
||||
{
|
||||
domain: 'persona.biography',
|
||||
statement: 'Minji often has family dinner in the evening.',
|
||||
topics: [{ name: 'Minji' }, { name: 'family dinner' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
], extractorPrompts),
|
||||
},
|
||||
});
|
||||
|
||||
expect(classifierPrompts).toHaveLength(3);
|
||||
expect(classifierPrompts[0]).toContain('Direction: inbound');
|
||||
expect(classifierPrompts[0]).toContain('이번주말에 등산 가고 싶어');
|
||||
expect(classifierPrompts[1]).toContain('Direction: outbound');
|
||||
expect(extractorPrompts).toHaveLength(2);
|
||||
expect(extractorPrompts[0]).toContain('stores a durable user preference');
|
||||
expect(extractorPrompts[1]).toContain('reveals a stable persona routine');
|
||||
|
||||
const facts = await listFactsForSpace(db, 'persona:minji');
|
||||
const hikingFact = facts.find((fact) => fact.statement === 'Shinwoo wants to go hiking on weekends.');
|
||||
const dinnerFact = facts.find((fact) => fact.statement === 'Minji often has family dinner in the evening.');
|
||||
|
||||
expect(hikingFact?.metadata).toMatchObject({
|
||||
boxbrain: {
|
||||
domain: 'persona.relationship',
|
||||
},
|
||||
conversationMemory: {
|
||||
turnId: expect.any(String),
|
||||
direction: 'inbound',
|
||||
counterpartId: 'user:shinwoo',
|
||||
counterpartDisplayName: 'Shinwoo',
|
||||
occurredAt: '2026-05-12T12:00:00.000Z',
|
||||
proactive: false,
|
||||
sourceMessage: '이번주말에 등산 가고 싶어',
|
||||
classifierReason: 'stores a durable user preference',
|
||||
},
|
||||
});
|
||||
expect(dinnerFact?.metadata).toMatchObject({
|
||||
boxbrain: {
|
||||
domain: 'persona.biography',
|
||||
},
|
||||
conversationMemory: {
|
||||
turnId: expect.any(String),
|
||||
direction: 'outbound',
|
||||
counterpartId: 'user:shinwoo',
|
||||
counterpartDisplayName: 'Shinwoo',
|
||||
occurredAt: '2026-05-12T12:00:00.000Z',
|
||||
proactive: false,
|
||||
sourceMessage: '저녁엔가족이랑먹어',
|
||||
classifierReason: 'reveals a stable persona routine',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('deduplicates repeated extracted conversation memories by statement and domain', async () => {
|
||||
const db = await createDb();
|
||||
await seedPersonaMemory(db);
|
||||
|
||||
await replyToConversation(db, {
|
||||
spaceName: 'persona:minji',
|
||||
counterpartId: 'user:shinwoo',
|
||||
counterpartDisplayName: 'Shinwoo',
|
||||
message: '나는 민트초코 좋아해',
|
||||
currentTime: '2026-05-12T12:00:00.000Z',
|
||||
mandatoryMemoryModel: createSelectionModel(['m1']),
|
||||
contextualMemoryModel: createSelectionModel([]),
|
||||
responseModel: createResponseModel({ mode: 'reply', messages: ['오 진짜?'] }),
|
||||
memoryPipeline: {
|
||||
classifierModel: createMemoryClassifier([
|
||||
{ shouldRemember: true, reason: 'stable preference' },
|
||||
{ shouldRemember: false, reason: 'reply is not worth storing' },
|
||||
]),
|
||||
extractorModel: createMemoryExtractor([
|
||||
{
|
||||
facts: [
|
||||
{
|
||||
domain: 'persona.relationship',
|
||||
statement: 'Shinwoo likes mint chocolate.',
|
||||
topics: [{ name: 'Shinwoo' }, { name: 'mint chocolate' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
await replyToConversation(db, {
|
||||
spaceName: 'persona:minji',
|
||||
counterpartId: 'user:shinwoo',
|
||||
counterpartDisplayName: 'Shinwoo',
|
||||
message: '나 아직도 민트초코 좋아해',
|
||||
currentTime: '2026-05-12T13:00:00.000Z',
|
||||
mandatoryMemoryModel: createSelectionModel(['m1']),
|
||||
contextualMemoryModel: createSelectionModel([]),
|
||||
responseModel: createResponseModel({ mode: 'reply', messages: ['기억하고있어'] }),
|
||||
memoryPipeline: {
|
||||
classifierModel: createMemoryClassifier([
|
||||
{ shouldRemember: true, reason: 'same stable preference' },
|
||||
{ shouldRemember: false, reason: 'reply is not worth storing' },
|
||||
]),
|
||||
extractorModel: createMemoryExtractor([
|
||||
{
|
||||
facts: [
|
||||
{
|
||||
domain: 'persona.relationship',
|
||||
statement: 'Shinwoo likes mint chocolate.',
|
||||
topics: [{ name: 'Shinwoo' }, { name: 'mint chocolate' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
const facts = await listFactsForSpace(db, 'persona:minji');
|
||||
expect(facts.filter((fact) => fact.statement === 'Shinwoo likes mint chocolate.')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('executes availability tool calls after farewell-style refusal messages', async () => {
|
||||
const db = await createDb();
|
||||
await seedPersonaMemory(db);
|
||||
@@ -208,6 +367,55 @@ function createResponseModel(plan: ConversationTurnPlan, prompts: string[] = [])
|
||||
};
|
||||
}
|
||||
|
||||
function createMemoryClassifier(
|
||||
results: ConversationMemoryClassificationResult[],
|
||||
prompts: string[] = [],
|
||||
): StructuredModelAdapter {
|
||||
const queue = [...results];
|
||||
return {
|
||||
provider: 'fake-structured',
|
||||
model: 'memory-classifier',
|
||||
async generateObject<TOutput>(request: { prompt: string }): Promise<TOutput> {
|
||||
prompts.push(request.prompt);
|
||||
const result = queue.shift();
|
||||
if (!result) {
|
||||
throw new Error('No queued conversation memory classification result.');
|
||||
}
|
||||
return result as TOutput;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createMemoryExtractor(
|
||||
results: ConversationMemoryExtractionResult[],
|
||||
prompts: string[] = [],
|
||||
): StructuredModelAdapter {
|
||||
const queue = [...results];
|
||||
return {
|
||||
provider: 'fake-structured',
|
||||
model: 'memory-extractor',
|
||||
async generateObject<TOutput>(request: { prompt: string }): Promise<TOutput> {
|
||||
prompts.push(request.prompt);
|
||||
const result = queue.shift();
|
||||
if (!result) {
|
||||
throw new Error('No queued conversation memory extraction result.');
|
||||
}
|
||||
return result as TOutput;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function listFactsForSpace(db: Awaited<ReturnType<typeof createDb>>, spaceName: string): Promise<Fact[]> {
|
||||
const topics = await db.listTopics({ includeFacts: true, spaceName });
|
||||
const byId = new Map<string, Fact>();
|
||||
for (const topic of topics) {
|
||||
for (const fact of topic.facts) {
|
||||
byId.set(fact.id, fact);
|
||||
}
|
||||
}
|
||||
return Array.from(byId.values());
|
||||
}
|
||||
|
||||
async function seedPersonaMemory(db: Awaited<ReturnType<typeof createDb>>) {
|
||||
await db.upsertSpace({
|
||||
name: 'persona:minji',
|
||||
|
||||
Reference in New Issue
Block a user