feat: add conversation memory pipeline
All checks were successful
npm release / verify (push) Successful in 13s
npm release / publish to npm (push) Successful in 11s

This commit is contained in:
2026-05-11 23:12:02 +09:00
parent baea23b8b0
commit 4baf056cd9
5 changed files with 506 additions and 2 deletions

View File

@@ -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',