feat: add persona initialization
This commit is contained in:
20
README.md
20
README.md
@@ -2,13 +2,21 @@
|
|||||||
|
|
||||||
BoxBrain is an IdentityDB-backed TypeScript framework for creating synthetic personas that can behave like human-like DM contacts.
|
BoxBrain is an IdentityDB-backed TypeScript framework for creating synthetic personas that can behave like human-like DM contacts.
|
||||||
|
|
||||||
The project is framework-first rather than product-first. It provides typed services and adapter contracts for:
|
The project is framework-first rather than product-first. The current foundation provides:
|
||||||
|
|
||||||
- initializing a persona from personality, history, values, preferences, and relationships
|
- provider-agnostic text, structured-output, and image adapter contracts
|
||||||
- storing one persona per IdentityDB memory space
|
- one IdentityDB memory space per persona
|
||||||
- generating and persisting schedule and availability facts
|
- persona initialization from personality, history, values, preferences, and relationships
|
||||||
- running DM-style conversations with short multi-message replies and human-like typing delays
|
- LLM-generated biography ingestion into IdentityDB fact drafts
|
||||||
- choosing AI text, structured-output, and image providers through simple adapters
|
- optional profile image generation through an image adapter
|
||||||
|
- human-like typing and first-reply delay utilities
|
||||||
|
|
||||||
|
Planned next APIs include:
|
||||||
|
|
||||||
|
- schedule generation and availability state persistence
|
||||||
|
- inbound DM-style conversation turns with mandatory/contextual memory retrieval
|
||||||
|
- proactive outbound messages without user input
|
||||||
|
- HTTP/RPC wrappers around the core library APIs
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './adapters';
|
export * from './adapters';
|
||||||
export * from './memory';
|
export * from './memory';
|
||||||
|
export * from './persona';
|
||||||
export * from './timing';
|
export * from './timing';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
|
|||||||
@@ -26,11 +26,12 @@ export async function persistFactDrafts(db: IdentityDB, input: PersistFactDrafts
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toAddFactInput(draft: BoxBrainFactDraft, input: PersistFactDraftsInput): AddFactInput {
|
function toAddFactInput(draft: BoxBrainFactDraft, input: PersistFactDraftsInput): AddFactInput {
|
||||||
|
const source = draft.source ?? input.source;
|
||||||
const factInput: AddFactInput = {
|
const factInput: AddFactInput = {
|
||||||
spaceName: input.spaceName,
|
spaceName: input.spaceName,
|
||||||
statement: draft.statement,
|
statement: draft.statement,
|
||||||
source: draft.source ?? input.source,
|
source,
|
||||||
metadata: withBoxBrainMetadata(draft.metadata, input.domain, input.source),
|
metadata: withBoxBrainMetadata(draft.metadata, input.domain, source),
|
||||||
topics: draft.topics.map(toTopicInput),
|
topics: draft.topics.map(toTopicInput),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
277
src/persona.ts
Normal file
277
src/persona.ts
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { IdentityDB } from 'identitydb';
|
||||||
|
import type { ImageModelAdapter, StructuredModelAdapter } from './adapters';
|
||||||
|
import { persistFactDrafts } from './memory';
|
||||||
|
import type { BoxBrainFactDraft, BoxBrainPersonaProfile } from './types';
|
||||||
|
|
||||||
|
export interface PersonaRelationshipInput {
|
||||||
|
name: string;
|
||||||
|
relationship: string;
|
||||||
|
description?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InitializePersonaInput {
|
||||||
|
id?: string | undefined;
|
||||||
|
displayName: string;
|
||||||
|
spaceName?: string | undefined;
|
||||||
|
personality: string;
|
||||||
|
history?: string | undefined;
|
||||||
|
values?: string[] | undefined;
|
||||||
|
likes?: string[] | undefined;
|
||||||
|
dislikes?: string[] | undefined;
|
||||||
|
relationships?: PersonaRelationshipInput[] | undefined;
|
||||||
|
currentDate?: string | undefined;
|
||||||
|
structuredModel: StructuredModelAdapter;
|
||||||
|
imageModel?: ImageModelAdapter | undefined;
|
||||||
|
generateProfileImage?: boolean | undefined;
|
||||||
|
reuseExistingSpace?: boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonaBiographyResult {
|
||||||
|
biography: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonaFactExtractionResult {
|
||||||
|
facts: BoxBrainFactDraft[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InitializedPersona extends BoxBrainPersonaProfile {
|
||||||
|
biography: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERSONA_BIOGRAPHY_SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['biography'],
|
||||||
|
properties: {
|
||||||
|
biography: { type: 'string', minLength: 1 },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const PERSONA_FACT_EXTRACTION_SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['facts'],
|
||||||
|
properties: {
|
||||||
|
facts: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['statement', 'topics'],
|
||||||
|
properties: {
|
||||||
|
statement: { type: 'string', minLength: 1 },
|
||||||
|
topics: {
|
||||||
|
type: 'array',
|
||||||
|
minItems: 1,
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['name'],
|
||||||
|
properties: { name: { type: 'string', minLength: 1 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export async function initializePersona(db: IdentityDB, input: InitializePersonaInput): Promise<InitializedPersona> {
|
||||||
|
const id = input.id ?? createPersonaId(input.displayName);
|
||||||
|
const spaceName = input.spaceName ?? `persona:${id}`;
|
||||||
|
const existingSpace = await db.getSpaceByName(spaceName);
|
||||||
|
|
||||||
|
if (existingSpace && !input.reuseExistingSpace) {
|
||||||
|
throw new Error(`Persona space already exists: ${spaceName}. Pass reuseExistingSpace to append to it intentionally.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.generateProfileImage && !input.imageModel) {
|
||||||
|
throw new Error('generateProfileImage requires an imageModel adapter.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const biography = assertPersonaBiographyResult(
|
||||||
|
await input.structuredModel.generateObject<PersonaBiographyResult>({
|
||||||
|
prompt: buildBiographyPrompt(input),
|
||||||
|
schema: PERSONA_BIOGRAPHY_SCHEMA,
|
||||||
|
metadata: {
|
||||||
|
boxbrainTask: 'persona.biography.generate',
|
||||||
|
personaId: id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const extracted = assertPersonaFactExtractionResult(
|
||||||
|
await input.structuredModel.generateObject<PersonaFactExtractionResult>({
|
||||||
|
prompt: buildFactExtractionPrompt(input.displayName, biography.biography),
|
||||||
|
schema: PERSONA_FACT_EXTRACTION_SCHEMA,
|
||||||
|
metadata: {
|
||||||
|
boxbrainTask: 'persona.biography.extract_facts',
|
||||||
|
personaId: id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let profileImageUrl: string | undefined;
|
||||||
|
if (input.generateProfileImage && input.imageModel) {
|
||||||
|
const image = await input.imageModel.generateImage({
|
||||||
|
prompt: buildProfileImagePrompt(input.displayName, biography.biography),
|
||||||
|
aspectRatio: 'square',
|
||||||
|
metadata: {
|
||||||
|
boxbrainTask: 'persona.profile_image.generate',
|
||||||
|
personaId: id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
profileImageUrl = image.url ?? image.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.upsertSpace({
|
||||||
|
name: spaceName,
|
||||||
|
description: `BoxBrain persona memory space for ${input.displayName}`,
|
||||||
|
metadata: {
|
||||||
|
boxbrain: {
|
||||||
|
domain: 'persona.space',
|
||||||
|
personaId: id,
|
||||||
|
displayName: input.displayName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await persistFactDrafts(db, {
|
||||||
|
spaceName,
|
||||||
|
domain: 'persona.biography',
|
||||||
|
source: `${input.structuredModel.provider}:${input.structuredModel.model}`,
|
||||||
|
facts: extracted.facts,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (profileImageUrl && input.imageModel) {
|
||||||
|
await persistFactDrafts(db, {
|
||||||
|
spaceName,
|
||||||
|
domain: 'persona.profile_image',
|
||||||
|
source: `${input.imageModel.provider}:${input.imageModel.model}`,
|
||||||
|
facts: [
|
||||||
|
{
|
||||||
|
statement: `${input.displayName} has a generated profile image at ${profileImageUrl}.`,
|
||||||
|
topics: [
|
||||||
|
{ name: input.displayName, category: 'entity' },
|
||||||
|
{ name: 'profile image', category: 'concept' },
|
||||||
|
],
|
||||||
|
metadata: { profileImageUrl },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const persona: InitializedPersona = {
|
||||||
|
id,
|
||||||
|
spaceName,
|
||||||
|
displayName: input.displayName,
|
||||||
|
biography: biography.biography,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (profileImageUrl !== undefined) {
|
||||||
|
persona.profileImageUrl = profileImageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return persona;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBiographyPrompt(input: InitializePersonaInput): string {
|
||||||
|
return [
|
||||||
|
'Create a concrete, detailed life biography for a synthetic persona from birth to now.',
|
||||||
|
'The biography must include personality, history, values, preferences, dislikes, and relationships when provided.',
|
||||||
|
`Current date: ${input.currentDate ?? new Date().toISOString().slice(0, 10)}`,
|
||||||
|
`Display name: ${input.displayName}`,
|
||||||
|
`Personality: ${input.personality}`,
|
||||||
|
optionalLine('History', input.history),
|
||||||
|
listLine('Values', input.values),
|
||||||
|
listLine('Likes', input.likes),
|
||||||
|
listLine('Dislikes', input.dislikes),
|
||||||
|
relationshipLine(input.relationships),
|
||||||
|
'Return structured output with a biography string.',
|
||||||
|
]
|
||||||
|
.filter((line): line is string => Boolean(line))
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFactExtractionPrompt(displayName: string, biography: string): string {
|
||||||
|
return [
|
||||||
|
`Split ${displayName}'s biography into concrete IdentityDB-ready facts.`,
|
||||||
|
'Every fact must have a statement and at least one topic.',
|
||||||
|
'Prefer specific topics for people, places, values, preferences, relationships, and time periods.',
|
||||||
|
`Biography:\n${biography}`,
|
||||||
|
'Return structured output with a facts array.',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProfileImagePrompt(displayName: string, biography: string): string {
|
||||||
|
return [
|
||||||
|
`Create a natural profile image for ${displayName}.`,
|
||||||
|
'The image should look like a believable DM profile picture, not a studio product shot.',
|
||||||
|
`Persona biography summary: ${biography}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPersonaId(displayName: string): string {
|
||||||
|
const normalized = displayName
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9가-힣]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
const prefix = normalized || 'persona';
|
||||||
|
|
||||||
|
return `${prefix}-${randomUUID()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPersonaBiographyResult(value: PersonaBiographyResult): PersonaBiographyResult {
|
||||||
|
if (!value || typeof value.biography !== 'string' || value.biography.trim().length === 0) {
|
||||||
|
throw new Error('Structured persona biography output must include a non-empty biography string.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPersonaFactExtractionResult(value: PersonaFactExtractionResult): PersonaFactExtractionResult {
|
||||||
|
if (!value || !Array.isArray(value.facts)) {
|
||||||
|
throw new Error('Structured persona fact extraction output must include a facts array.');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let factIndex = 0; factIndex < value.facts.length; factIndex += 1) {
|
||||||
|
const fact = value.facts[factIndex]!;
|
||||||
|
if (!fact || typeof fact.statement !== 'string' || fact.statement.trim().length === 0) {
|
||||||
|
throw new Error(`Structured persona fact extraction output has an invalid statement at index ${factIndex}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(fact.topics) || fact.topics.length === 0) {
|
||||||
|
throw new Error(`Structured persona fact extraction output has no topics at index ${factIndex}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let topicIndex = 0; topicIndex < fact.topics.length; topicIndex += 1) {
|
||||||
|
const topic = fact.topics[topicIndex]!;
|
||||||
|
if (!topic || typeof topic.name !== 'string' || topic.name.trim().length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Structured persona fact extraction output has an invalid topic name at fact index ${factIndex}, topic index ${topicIndex}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalLine(label: string, value: string | undefined): string | undefined {
|
||||||
|
return value ? `${label}: ${value}` : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listLine(label: string, values: string[] | undefined): string | undefined {
|
||||||
|
return values && values.length > 0 ? `${label}: ${values.join(', ')}` : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relationshipLine(relationships: PersonaRelationshipInput[] | undefined): string | undefined {
|
||||||
|
if (!relationships || relationships.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Relationships: ${relationships
|
||||||
|
.map((relationship) => {
|
||||||
|
const details = relationship.description ? ` (${relationship.description})` : '';
|
||||||
|
return `${relationship.name} — ${relationship.relationship}${details}`;
|
||||||
|
})
|
||||||
|
.join('; ')}`;
|
||||||
|
}
|
||||||
@@ -57,4 +57,28 @@ describe('persistFactDrafts', () => {
|
|||||||
expect(facts).toEqual([]);
|
expect(facts).toEqual([]);
|
||||||
expect(await db.getSpaceByName('persona:empty')).toBeNull();
|
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