feat: add persona initialization
This commit is contained in:
@@ -57,4 +57,28 @@ describe('persistFactDrafts', () => {
|
||||
expect(facts).toEqual([]);
|
||||
expect(await db.getSpaceByName('persona:empty')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps fact source metadata consistent when a draft overrides source', async () => {
|
||||
const db = await createDb();
|
||||
|
||||
const [fact] = await persistFactDrafts(db, {
|
||||
spaceName: 'persona:source-test',
|
||||
domain: 'persona.biography',
|
||||
source: 'boxbrain:batch',
|
||||
facts: [
|
||||
{
|
||||
statement: 'Minji wrote a diary entry.',
|
||||
source: 'boxbrain:draft',
|
||||
topics: [{ name: 'Minji' }, { name: 'diary entry' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(fact?.source).toBe('boxbrain:draft');
|
||||
expect(fact?.metadata).toMatchObject({
|
||||
boxbrain: {
|
||||
source: 'boxbrain:draft',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
263
tests/persona.test.ts
Normal file
263
tests/persona.test.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { IdentityDB } from 'identitydb';
|
||||
import { initializePersona, type PersonaBiographyResult, type PersonaFactExtractionResult } from '../src';
|
||||
import type { ImageModelAdapter, StructuredModelAdapter } from '../src';
|
||||
|
||||
const openDbs: IdentityDB[] = [];
|
||||
|
||||
async function createDb() {
|
||||
const db = await IdentityDB.connect({ client: 'sqlite', filename: ':memory:' });
|
||||
await db.initialize();
|
||||
openDbs.push(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
while (openDbs.length > 0) {
|
||||
await openDbs.pop()!.close();
|
||||
}
|
||||
});
|
||||
|
||||
describe('initializePersona', () => {
|
||||
it('generates a biography, extracts facts, and stores them in the persona space', async () => {
|
||||
const db = await createDb();
|
||||
const prompts: string[] = [];
|
||||
const structured = createStructuredAdapter(prompts);
|
||||
|
||||
const persona = await initializePersona(db, {
|
||||
id: 'minji',
|
||||
displayName: 'Minji',
|
||||
personality: 'Quiet, playful, observant.',
|
||||
history: 'Grew up near Busan and studied design.',
|
||||
values: ['loyalty', 'creative honesty'],
|
||||
likes: ['quiet cafés'],
|
||||
dislikes: ['loud arguments'],
|
||||
relationships: [{ name: 'Haru', relationship: 'best friend', description: 'childhood friend' }],
|
||||
currentDate: '2026-05-11',
|
||||
structuredModel: structured,
|
||||
});
|
||||
|
||||
expect(persona).toMatchObject({
|
||||
id: 'minji',
|
||||
displayName: 'Minji',
|
||||
spaceName: 'persona:minji',
|
||||
biography: 'Minji grew up near the sea and values loyal friendships.',
|
||||
});
|
||||
expect(prompts[0]).toContain('Quiet, playful, observant.');
|
||||
expect(prompts[0]).toContain('creative honesty');
|
||||
expect(prompts[0]).toContain('Haru');
|
||||
expect(prompts[0]).toContain('Current date: 2026-05-11');
|
||||
expect(prompts[1]).toContain('Minji grew up near the sea');
|
||||
|
||||
const facts = await db.getTopicFacts('Minji', { spaceName: persona.spaceName });
|
||||
expect(facts.map((fact) => fact.statement)).toContain('Minji grew up near the sea.');
|
||||
});
|
||||
|
||||
it('does not call the image adapter unless a profile image is requested', async () => {
|
||||
const db = await createDb();
|
||||
const structured = createStructuredAdapter([]);
|
||||
let imageCalls = 0;
|
||||
const imageModel: ImageModelAdapter = {
|
||||
provider: 'fake-image',
|
||||
model: 'fake-image-model',
|
||||
async generateImage() {
|
||||
imageCalls += 1;
|
||||
return { url: 'https://example.test/minji.png' };
|
||||
},
|
||||
};
|
||||
|
||||
const persona = await initializePersona(db, {
|
||||
id: 'minji',
|
||||
displayName: 'Minji',
|
||||
personality: 'Quiet.',
|
||||
structuredModel: structured,
|
||||
imageModel,
|
||||
});
|
||||
|
||||
expect(persona.profileImageUrl).toBeUndefined();
|
||||
expect(imageCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('can generate and store a persona profile image when requested', async () => {
|
||||
const db = await createDb();
|
||||
const structured = createStructuredAdapter([]);
|
||||
let imagePrompt = '';
|
||||
const imageModel: ImageModelAdapter = {
|
||||
provider: 'fake-image',
|
||||
model: 'fake-image-model',
|
||||
async generateImage(request) {
|
||||
imagePrompt = request.prompt;
|
||||
return { url: 'https://example.test/minji.png' };
|
||||
},
|
||||
};
|
||||
|
||||
const persona = await initializePersona(db, {
|
||||
id: 'minji',
|
||||
displayName: 'Minji',
|
||||
personality: 'Quiet.',
|
||||
structuredModel: structured,
|
||||
imageModel,
|
||||
generateProfileImage: true,
|
||||
});
|
||||
|
||||
expect(imagePrompt).toContain('Minji');
|
||||
expect(persona.profileImageUrl).toBe('https://example.test/minji.png');
|
||||
|
||||
const facts = await db.getTopicFacts('profile image', { spaceName: persona.spaceName });
|
||||
expect(facts.map((fact) => fact.statement)).toContain('Minji has a generated profile image at https://example.test/minji.png.');
|
||||
});
|
||||
|
||||
it('generates unique default persona spaces when no id is supplied', async () => {
|
||||
const db = await createDb();
|
||||
|
||||
const first = await initializePersona(db, {
|
||||
displayName: 'Minji',
|
||||
personality: 'Quiet.',
|
||||
structuredModel: createStructuredAdapter([]),
|
||||
});
|
||||
const second = await initializePersona(db, {
|
||||
displayName: 'Minji',
|
||||
personality: 'Quiet.',
|
||||
structuredModel: createStructuredAdapter([]),
|
||||
});
|
||||
|
||||
expect(first.id).not.toBe(second.id);
|
||||
expect(first.spaceName).not.toBe(second.spaceName);
|
||||
});
|
||||
|
||||
it('rejects accidental reuse of an existing persona space by default', async () => {
|
||||
const db = await createDb();
|
||||
await initializePersona(db, {
|
||||
id: 'minji',
|
||||
displayName: 'Minji',
|
||||
personality: 'Quiet.',
|
||||
structuredModel: createStructuredAdapter([]),
|
||||
});
|
||||
|
||||
await expect(
|
||||
initializePersona(db, {
|
||||
id: 'minji',
|
||||
displayName: 'Minji Again',
|
||||
personality: 'Different.',
|
||||
structuredModel: createStructuredAdapter([]),
|
||||
}),
|
||||
).rejects.toThrow(/already exists/);
|
||||
});
|
||||
|
||||
it('allows explicit reuse of an existing persona space', async () => {
|
||||
const db = await createDb();
|
||||
await initializePersona(db, {
|
||||
id: 'minji',
|
||||
displayName: 'Minji',
|
||||
personality: 'Quiet.',
|
||||
structuredModel: createStructuredAdapter([]),
|
||||
});
|
||||
|
||||
const persona = await initializePersona(db, {
|
||||
id: 'minji',
|
||||
displayName: 'Minji',
|
||||
personality: 'Quiet.',
|
||||
structuredModel: createStructuredAdapter([]),
|
||||
reuseExistingSpace: true,
|
||||
});
|
||||
|
||||
expect(persona.spaceName).toBe('persona:minji');
|
||||
});
|
||||
|
||||
it('rejects malformed structured biography output', async () => {
|
||||
const db = await createDb();
|
||||
const malformed: StructuredModelAdapter = {
|
||||
provider: 'fake-structured',
|
||||
model: 'fake-structured-model',
|
||||
async generateObject<TOutput>(): Promise<TOutput> {
|
||||
return { biography: '' } as TOutput;
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
initializePersona(db, {
|
||||
id: 'broken',
|
||||
displayName: 'Broken',
|
||||
personality: 'Quiet.',
|
||||
structuredModel: malformed,
|
||||
}),
|
||||
).rejects.toThrow(/biography/);
|
||||
expect(await db.getSpaceByName('persona:broken')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects malformed extracted fact topics before creating a space', async () => {
|
||||
const db = await createDb();
|
||||
const malformedFacts: StructuredModelAdapter = {
|
||||
provider: 'fake-structured',
|
||||
model: 'fake-structured-model',
|
||||
async generateObject<TOutput>(request: { prompt: string }): Promise<TOutput> {
|
||||
if (request.prompt.includes('Create a concrete')) {
|
||||
return { biography: 'Broken has a biography.' } as TOutput;
|
||||
}
|
||||
|
||||
return {
|
||||
facts: [
|
||||
{
|
||||
statement: 'Broken has a malformed topic.',
|
||||
topics: [{}],
|
||||
},
|
||||
],
|
||||
} as TOutput;
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
initializePersona(db, {
|
||||
id: 'broken-topic',
|
||||
displayName: 'Broken Topic',
|
||||
personality: 'Quiet.',
|
||||
structuredModel: malformedFacts,
|
||||
}),
|
||||
).rejects.toThrow(/topic name/);
|
||||
expect(await db.getSpaceByName('persona:broken-topic')).toBeNull();
|
||||
});
|
||||
|
||||
it('requires an image model when profile image generation is requested', async () => {
|
||||
const db = await createDb();
|
||||
|
||||
await expect(
|
||||
initializePersona(db, {
|
||||
id: 'minji',
|
||||
displayName: 'Minji',
|
||||
personality: 'Quiet.',
|
||||
structuredModel: createStructuredAdapter([]),
|
||||
generateProfileImage: true,
|
||||
}),
|
||||
).rejects.toThrow(/imageModel/);
|
||||
});
|
||||
});
|
||||
|
||||
function createStructuredAdapter(prompts: string[]): StructuredModelAdapter {
|
||||
let call = 0;
|
||||
return {
|
||||
provider: 'fake-structured',
|
||||
model: 'fake-structured-model',
|
||||
async generateObject<TOutput>(request: { prompt: string }): Promise<TOutput> {
|
||||
prompts.push(request.prompt);
|
||||
call += 1;
|
||||
if (call === 1) {
|
||||
return {
|
||||
biography: 'Minji grew up near the sea and values loyal friendships.',
|
||||
} satisfies PersonaBiographyResult as TOutput;
|
||||
}
|
||||
|
||||
return {
|
||||
facts: [
|
||||
{
|
||||
statement: 'Minji grew up near the sea.',
|
||||
topics: [{ name: 'Minji' }, { name: 'sea' }],
|
||||
},
|
||||
{
|
||||
statement: 'Minji values loyal friendships.',
|
||||
topics: [{ name: 'Minji' }, { name: 'loyal friendships' }],
|
||||
},
|
||||
],
|
||||
} satisfies PersonaFactExtractionResult as TOutput;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user