mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-28 13:39:28 +07:00
✨ feat(memory-user-memory,database,userMemories): implemented user memory persona (#11838)
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
// @vitest-environment node
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { getTestDB } from '../../../core/getTestDB';
|
||||
import { userPersonaDocumentHistories, userPersonaDocuments, users } from '../../../schemas';
|
||||
import { LobeChatDatabase } from '../../../type';
|
||||
import { UserPersonaModel } from '../persona';
|
||||
|
||||
const userId = 'persona-user';
|
||||
|
||||
let personaModel: UserPersonaModel;
|
||||
const serverDB: LobeChatDatabase = await getTestDB();
|
||||
|
||||
beforeEach(async () => {
|
||||
await serverDB.delete(userPersonaDocumentHistories);
|
||||
await serverDB.delete(userPersonaDocuments);
|
||||
await serverDB.delete(users);
|
||||
|
||||
await serverDB.insert(users).values([{ id: userId }]);
|
||||
|
||||
personaModel = new UserPersonaModel(serverDB, userId);
|
||||
});
|
||||
|
||||
describe('UserPersonaModel', () => {
|
||||
it('creates a new persona document with optional diff', async () => {
|
||||
const { document, diff } = await personaModel.upsertPersona({
|
||||
diffPersona: '- added intro',
|
||||
editedBy: 'user',
|
||||
memoryIds: ['mem-1'],
|
||||
reasoning: 'First draft',
|
||||
snapshot: '# Persona',
|
||||
sourceIds: ['src-1'],
|
||||
persona: '# Persona',
|
||||
});
|
||||
|
||||
expect(document.userId).toBe(userId);
|
||||
expect(document.version).toBe(1);
|
||||
expect(document.persona).toBe('# Persona');
|
||||
expect(diff?.previousVersion ?? undefined).toBeUndefined();
|
||||
expect(diff?.nextVersion).toBe(1);
|
||||
expect(diff?.memoryIds).toEqual(['mem-1']);
|
||||
expect(diff?.sourceIds).toEqual(['src-1']);
|
||||
});
|
||||
|
||||
it('increments version and records diff on update', async () => {
|
||||
await personaModel.upsertPersona({
|
||||
persona: '# v1',
|
||||
});
|
||||
|
||||
const { document, diff } = await personaModel.upsertPersona({
|
||||
diffPersona: '- updated section',
|
||||
reasoning: 'Second draft',
|
||||
memoryIds: ['mem-2'],
|
||||
snapshot: '# v2',
|
||||
sourceIds: ['src-2'],
|
||||
persona: '# v2',
|
||||
});
|
||||
|
||||
expect(document.version).toBe(2);
|
||||
expect(diff?.previousVersion).toBe(1);
|
||||
expect(diff?.nextVersion).toBe(2);
|
||||
expect(diff?.personaId).toBe(document.id);
|
||||
|
||||
const persisted = await serverDB.query.userPersonaDocumentHistories.findMany({
|
||||
where: (t, { eq }) => eq(t.userId, userId),
|
||||
});
|
||||
expect(persisted).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('skips diff insert when no diff content supplied', async () => {
|
||||
const { diff } = await personaModel.upsertPersona({
|
||||
persona: '# only persona',
|
||||
});
|
||||
|
||||
expect(diff).toBeUndefined();
|
||||
const persisted = await serverDB.query.userPersonaDocumentHistories.findMany({
|
||||
where: (t, { eq }) => eq(t.userId, userId),
|
||||
});
|
||||
expect(persisted).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns latest document for user', async () => {
|
||||
await personaModel.upsertPersona({ persona: '# v1' });
|
||||
await personaModel.upsertPersona({ persona: '# v2' });
|
||||
|
||||
const latest = await personaModel.getLatestPersonaDocument();
|
||||
expect(latest?.persona).toBe('# v2');
|
||||
expect(latest?.version).toBe(2);
|
||||
});
|
||||
|
||||
it('lists diffs ordered by createdAt desc', async () => {
|
||||
await personaModel.upsertPersona({
|
||||
diffPersona: '- change',
|
||||
memoryIds: ['mem-1'],
|
||||
sourceIds: ['src-1'],
|
||||
persona: '# v1',
|
||||
});
|
||||
|
||||
await personaModel.upsertPersona({
|
||||
diffPersona: '- change 2',
|
||||
memoryIds: ['mem-2'],
|
||||
sourceIds: ['src-2'],
|
||||
persona: '# v2',
|
||||
});
|
||||
|
||||
const diffs = await personaModel.listDiffs();
|
||||
expect(diffs).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -3,5 +3,6 @@ export * from './context';
|
||||
export * from './experience';
|
||||
export * from './identity';
|
||||
export * from './model';
|
||||
export * from './persona';
|
||||
export * from './preference';
|
||||
export * from './sources/benchmarkLoCoMo';
|
||||
|
||||
150
packages/database/src/models/userMemory/persona.ts
Normal file
150
packages/database/src/models/userMemory/persona.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
|
||||
import { userPersonaDocumentHistories, userPersonaDocuments } from '../../schemas';
|
||||
import type {
|
||||
NewUserPersonaDocument,
|
||||
NewUserPersonaDocumentHistoriesItem,
|
||||
UserPersonaDocument,
|
||||
UserPersonaDocumentHistoriesItem,
|
||||
} from '../../schemas';
|
||||
import { LobeChatDatabase } from '../../type';
|
||||
|
||||
export interface UpsertUserPersonaParams {
|
||||
capturedAt?: Date;
|
||||
diffPersona?: string | null;
|
||||
diffTagline?: string | null;
|
||||
editedBy?: 'user' | 'agent' | 'agent_tool';
|
||||
memoryIds?: string[] | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
persona: string;
|
||||
profile?: string | null;
|
||||
reasoning?: string | null;
|
||||
snapshot?: string | null;
|
||||
sourceIds?: string[] | null;
|
||||
tagline?: string | null;
|
||||
}
|
||||
|
||||
export class UserPersonaModel {
|
||||
private readonly db: LobeChatDatabase;
|
||||
private readonly userId: string;
|
||||
|
||||
constructor(db: LobeChatDatabase, userId: string) {
|
||||
this.db = db;
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
getLatestPersonaDocument = async (profile = 'default') => {
|
||||
return this.db.query.userPersonaDocuments.findFirst({
|
||||
orderBy: [desc(userPersonaDocuments.version), desc(userPersonaDocuments.updatedAt)],
|
||||
where: and(
|
||||
eq(userPersonaDocuments.userId, this.userId),
|
||||
eq(userPersonaDocuments.profile, profile),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
// Alias for consistency with other models
|
||||
getLatestDocument = async (profile = 'default') => this.getLatestPersonaDocument(profile);
|
||||
|
||||
listDiffs = async (limit = 50, profile = 'default') => {
|
||||
return this.db.query.userPersonaDocumentHistories.findMany({
|
||||
limit,
|
||||
orderBy: [desc(userPersonaDocumentHistories.createdAt)],
|
||||
where: and(
|
||||
eq(userPersonaDocumentHistories.userId, this.userId),
|
||||
eq(userPersonaDocumentHistories.profile, profile),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
appendDiff = async (
|
||||
params: Omit<NewUserPersonaDocumentHistoriesItem, 'id' | 'userId'> & { personaId: string },
|
||||
): Promise<UserPersonaDocumentHistoriesItem> => {
|
||||
const [result] = await this.db
|
||||
.insert(userPersonaDocumentHistories)
|
||||
.values({ ...params, userId: this.userId })
|
||||
.returning();
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
upsertPersona = async (
|
||||
params: UpsertUserPersonaParams,
|
||||
): Promise<{ diff?: UserPersonaDocumentHistoriesItem; document: UserPersonaDocument }> => {
|
||||
return this.db.transaction(async (tx) => {
|
||||
const existing = await tx.query.userPersonaDocuments.findFirst({
|
||||
where: and(
|
||||
eq(userPersonaDocuments.userId, this.userId),
|
||||
eq(userPersonaDocuments.profile, params.profile ?? 'default'),
|
||||
),
|
||||
});
|
||||
const nextVersion = (existing?.version ?? 0) + 1;
|
||||
|
||||
const baseDocument: Omit<NewUserPersonaDocument, 'id' | 'userId'> = {
|
||||
capturedAt: params.capturedAt,
|
||||
memoryIds: params.memoryIds ?? undefined,
|
||||
metadata: params.metadata ?? undefined,
|
||||
persona: params.persona,
|
||||
profile: params.profile ?? 'default',
|
||||
sourceIds: params.sourceIds ?? undefined,
|
||||
tagline: params.tagline ?? undefined,
|
||||
version: nextVersion,
|
||||
};
|
||||
|
||||
let document: UserPersonaDocument;
|
||||
|
||||
if (existing) {
|
||||
[document] = await tx
|
||||
.update(userPersonaDocuments)
|
||||
.set({ ...baseDocument, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(userPersonaDocuments.id, existing.id),
|
||||
eq(userPersonaDocuments.userId, this.userId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
} else {
|
||||
[document] = await tx
|
||||
.insert(userPersonaDocuments)
|
||||
.values({ ...baseDocument, userId: this.userId })
|
||||
.returning();
|
||||
}
|
||||
|
||||
let diff: UserPersonaDocumentHistoriesItem | undefined;
|
||||
const hasDiff =
|
||||
params.diffPersona ||
|
||||
params.diffTagline ||
|
||||
params.snapshot ||
|
||||
params.reasoning ||
|
||||
(params.memoryIds && params.memoryIds.length > 0) ||
|
||||
(params.sourceIds && params.sourceIds.length > 0);
|
||||
|
||||
if (hasDiff) {
|
||||
[diff] = await tx
|
||||
.insert(userPersonaDocumentHistories)
|
||||
.values({
|
||||
capturedAt: params.capturedAt,
|
||||
diffPersona: params.diffPersona ?? undefined,
|
||||
diffTagline: params.diffTagline ?? undefined,
|
||||
editedBy: params.editedBy ?? 'agent',
|
||||
memoryIds: params.memoryIds ?? undefined,
|
||||
metadata: params.metadata ?? undefined,
|
||||
nextVersion: document.version,
|
||||
personaId: document.id,
|
||||
previousVersion: existing?.version,
|
||||
profile: document.profile,
|
||||
reasoning: params.reasoning ?? undefined,
|
||||
snapshot: params.snapshot ?? params.persona,
|
||||
snapshotPersona: document.persona,
|
||||
snapshotTagline: document.tagline,
|
||||
sourceIds: params.sourceIds ?? undefined,
|
||||
userId: this.userId,
|
||||
})
|
||||
.returning();
|
||||
}
|
||||
|
||||
return { diff, document };
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -60,8 +60,14 @@ export const userMemoriesContexts = pgTable(
|
||||
metadata: jsonb('metadata').$type<Record<string, unknown>>(),
|
||||
tags: text('tags').array(),
|
||||
|
||||
associatedObjects: jsonb('associated_objects').$type<{ extra?: Record<string, unknown>, name?: string, type?: string }[]>(),
|
||||
associatedSubjects: jsonb('associated_subjects').$type<{ extra?: Record<string, unknown>, name?: string, type?: string }[]>(),
|
||||
associatedObjects:
|
||||
jsonb('associated_objects').$type<
|
||||
{ extra?: Record<string, unknown>; name?: string; type?: string }[]
|
||||
>(),
|
||||
associatedSubjects:
|
||||
jsonb('associated_subjects').$type<
|
||||
{ extra?: Record<string, unknown>; name?: string; type?: string }[]
|
||||
>(),
|
||||
|
||||
title: text('title'),
|
||||
description: text('description'),
|
||||
@@ -145,22 +151,28 @@ export const userMemoriesActivities = pgTable(
|
||||
startsAt: timestamptz('starts_at'),
|
||||
endsAt: timestamptz('ends_at'),
|
||||
|
||||
associatedObjects: jsonb('associated_objects').$type<{
|
||||
extra?: Record<string, unknown>,
|
||||
name?: string,
|
||||
type?: string
|
||||
}[]>(),
|
||||
associatedSubjects: jsonb('associated_subjects').$type<{
|
||||
extra?: Record<string, unknown>,
|
||||
name?: string,
|
||||
type?: string
|
||||
}[]>(),
|
||||
associatedLocations: jsonb('associated_locations').$type<{
|
||||
address?: string;
|
||||
name?: string;
|
||||
tags?: string[];
|
||||
type?: string;
|
||||
}[]>(),
|
||||
associatedObjects: jsonb('associated_objects').$type<
|
||||
{
|
||||
extra?: Record<string, unknown>;
|
||||
name?: string;
|
||||
type?: string;
|
||||
}[]
|
||||
>(),
|
||||
associatedSubjects: jsonb('associated_subjects').$type<
|
||||
{
|
||||
extra?: Record<string, unknown>;
|
||||
name?: string;
|
||||
type?: string;
|
||||
}[]
|
||||
>(),
|
||||
associatedLocations: jsonb('associated_locations').$type<
|
||||
{
|
||||
address?: string;
|
||||
name?: string;
|
||||
tags?: string[];
|
||||
type?: string;
|
||||
}[]
|
||||
>(),
|
||||
|
||||
notes: text('notes'),
|
||||
narrative: text('narrative'),
|
||||
@@ -313,3 +325,5 @@ export type UserMemoryActivitiesWithoutVectors = Omit<
|
||||
'narrativeVector' | 'feedbackVector'
|
||||
>;
|
||||
export type NewUserMemoryActivity = typeof userMemoriesActivities.$inferInsert;
|
||||
|
||||
export * from './persona';
|
||||
|
||||
@@ -5,6 +5,12 @@ import { createNanoId } from '../../utils/idGenerator';
|
||||
import { timestamps, timestamptz, varchar255 } from '../_helpers';
|
||||
import { users } from '../user';
|
||||
|
||||
// TODO(@nekomeowww): add a comment/annotation layer for personas.
|
||||
// Rationale: the persona writer often wants to flag clarifications or open questions (e.g. “need team name”, “confirm Apple Developer plan”)
|
||||
// without polluting the readable persona text. A small JSONB comments array here (section + target hash + message + type) would let us
|
||||
// persist those notes, render inline highlights in the UI, and feed precise prompts back into the next persona write. This keeps the
|
||||
// narrative clean, improves user engagement (they see exactly what to answer), and gives us structured signals for future updates.
|
||||
|
||||
export const userPersonaDocuments = pgTable(
|
||||
'user_memory_persona_documents',
|
||||
{
|
||||
@@ -77,5 +83,4 @@ export type UserPersonaDocument = typeof userPersonaDocuments.$inferSelect;
|
||||
export type NewUserPersonaDocument = typeof userPersonaDocuments.$inferInsert;
|
||||
|
||||
export type UserPersonaDocumentHistoriesItem = typeof userPersonaDocumentHistories.$inferSelect;
|
||||
export type NewUserPersonaDocumentHistoriesItem =
|
||||
typeof userPersonaDocumentHistories.$inferInsert;
|
||||
export type NewUserPersonaDocumentHistoriesItem = typeof userPersonaDocumentHistories.$inferInsert;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
description: User persona prompt regression
|
||||
|
||||
providers:
|
||||
- id: openai:chat:gpt-5-mini
|
||||
config:
|
||||
tools: file://../../response-formats/persona-tools.json
|
||||
tool_choice: required
|
||||
|
||||
prompts:
|
||||
- file://./prompt.ts
|
||||
|
||||
tests:
|
||||
- file://./tests/cases.ts
|
||||
@@ -0,0 +1,39 @@
|
||||
import { renderPlaceholderTemplate } from '@lobechat/context-engine';
|
||||
|
||||
import { userPersonaPrompt } from '../../../src/prompts/persona';
|
||||
|
||||
interface PersonaPromptVars {
|
||||
existingPersona?: string;
|
||||
language: string;
|
||||
personaNotes?: string;
|
||||
recentEvents?: string;
|
||||
retrievedMemories?: string;
|
||||
userProfile?: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export default async function generatePrompt({ vars }: { vars: PersonaPromptVars }) {
|
||||
const system = renderPlaceholderTemplate(userPersonaPrompt, {
|
||||
language: vars.language,
|
||||
topK: 10,
|
||||
username: vars.username,
|
||||
});
|
||||
|
||||
const userSections = [
|
||||
'## Existing Persona (baseline)',
|
||||
vars.existingPersona || 'No existing persona provided.',
|
||||
'## Retrieved Memories / Signals',
|
||||
vars.retrievedMemories || 'N/A',
|
||||
'## Recent Events or Highlights',
|
||||
vars.recentEvents || 'N/A',
|
||||
'## User Provided Notes or Requests',
|
||||
vars.personaNotes || 'N/A',
|
||||
'## Extra Profile Context',
|
||||
vars.userProfile || 'N/A',
|
||||
].join('\n\n');
|
||||
|
||||
return [
|
||||
{ content: system, role: 'system' },
|
||||
{ content: userSections, role: 'user' },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
const toolCallAssert = {
|
||||
type: 'javascript',
|
||||
value: `
|
||||
const calls = Array.isArray(output) ? output : [];
|
||||
if (calls.length === 0) return false;
|
||||
|
||||
return calls.every((call) => {
|
||||
const fnName = call.function?.name || call.name;
|
||||
if (fnName !== 'commit_user_persona') return false;
|
||||
|
||||
const rawArgs = call.function?.arguments ?? call.arguments;
|
||||
let args = {};
|
||||
if (typeof rawArgs === 'string') {
|
||||
try { args = JSON.parse(rawArgs); } catch { return false; }
|
||||
} else {
|
||||
args = rawArgs || {};
|
||||
}
|
||||
|
||||
return typeof args.persona === 'string' && args.persona.trim().length > 10;
|
||||
});
|
||||
`,
|
||||
};
|
||||
|
||||
const rubric = {
|
||||
provider: 'openai:gpt-5-mini',
|
||||
type: 'llm-rubric',
|
||||
value:
|
||||
'Should return a tool call to commit_user_persona with a meaningful second-person persona and concise diff/summary.',
|
||||
};
|
||||
|
||||
export default [
|
||||
{
|
||||
assert: [{ type: 'is-valid-openai-tools-call' }, toolCallAssert, rubric],
|
||||
description: 'Generates a persona with baseline and events',
|
||||
vars: {
|
||||
existingPersona: '# About User\n- Loves TypeScript\n- Works on LobeHub',
|
||||
language: '简体中文',
|
||||
personaNotes: '- Keep concise',
|
||||
recentEvents: '- Shipped memory feature\n- Joined community call',
|
||||
retrievedMemories: '- Preference: dark mode\n- Context: building AI workspace',
|
||||
userProfile: '- Developer, open source contributor',
|
||||
username: 'User',
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
@@ -0,0 +1,30 @@
|
||||
[
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "commit_user_persona",
|
||||
"description": "Persist an updated user persona document that summarizes the user, preferences, relationships, and recent events.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"persona": { "type": "string", "description": "Complete Markdown persona for the user" },
|
||||
"summary": { "type": "string", "description": "Executive summary (2-3 lines)" },
|
||||
"diff": { "type": "string", "description": "Bullet list of changes applied this run" },
|
||||
"reasoning": { "type": "string", "description": "Why these changes were applied" },
|
||||
"memoryIds": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Related memory IDs used to craft the persona"
|
||||
},
|
||||
"sourceIds": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Source or topic IDs tied to this update"
|
||||
}
|
||||
},
|
||||
"required": ["persona"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"persona": { "type": "string" },
|
||||
"summary": { "type": "string" },
|
||||
"diff": { "type": "string" },
|
||||
"reasoning": { "type": "string" },
|
||||
"memoryIds": { "type": "array", "items": { "type": "string" } },
|
||||
"sourceIds": { "type": "array", "items": { "type": "string" } }
|
||||
},
|
||||
"required": ["persona"],
|
||||
"type": "object"
|
||||
}
|
||||
@@ -4,6 +4,7 @@ description: LobeHub Prompts (memory-user-memory) Testing Suite
|
||||
testPaths:
|
||||
- promptfoo/evals/identity/with-s3-trace/eval.yaml
|
||||
- promptfoo/evals/activity/basic/eval.yaml
|
||||
- promptfoo/evals/persona/eval.yaml
|
||||
|
||||
# Output configuration
|
||||
outputPath: promptfoo-results.json
|
||||
|
||||
@@ -3,4 +3,5 @@ export { ContextExtractor } from './context';
|
||||
export { ExperienceExtractor } from './experience';
|
||||
export { UserMemoryGateKeeper } from './gatekeeper';
|
||||
export { IdentityExtractor } from './identity';
|
||||
export { UserPersonaExtractor } from './persona';
|
||||
export { PreferenceExtractor } from './preference';
|
||||
|
||||
83
packages/memory-user-memory/src/extractors/persona.test.ts
Normal file
83
packages/memory-user-memory/src/extractors/persona.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { renderPlaceholderTemplate } from '@lobechat/context-engine';
|
||||
import type { ModelRuntime } from '@lobechat/model-runtime';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { userPersonaPrompt } from '../prompts';
|
||||
import { PersonaTemplateProps } from '../types';
|
||||
import { UserPersonaExtractor } from './persona';
|
||||
|
||||
const runtimeMock = { generateObject: vi.fn() } as unknown as ModelRuntime;
|
||||
const extractorConfig = {
|
||||
agent: 'user-persona' as const,
|
||||
model: 'gpt-mock',
|
||||
modelRuntime: runtimeMock,
|
||||
};
|
||||
|
||||
const templateOptions: PersonaTemplateProps = {
|
||||
existingPersona: '# Existing',
|
||||
language: 'English',
|
||||
recentEvents: '- Event 1',
|
||||
retrievedMemories: '- mem',
|
||||
personaNotes: '- note',
|
||||
userProfile: '- profile',
|
||||
username: 'User',
|
||||
};
|
||||
|
||||
describe('UserPersonaExtractor', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('exposes function tool for committing personas', async () => {
|
||||
const extractor = new UserPersonaExtractor(extractorConfig);
|
||||
const tools = (extractor as any).getTools();
|
||||
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools?.[0].function?.name).toBe('commit_user_persona');
|
||||
expect((extractor as any).getSchema()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('renders user prompt with provided sections', async () => {
|
||||
const extractor = new UserPersonaExtractor(extractorConfig);
|
||||
await extractor.ensurePromptTemplate();
|
||||
|
||||
const prompt = extractor.buildUserPrompt(templateOptions);
|
||||
expect(prompt).toContain('## Existing Persona');
|
||||
expect(prompt).toContain('# Existing');
|
||||
expect(prompt).toContain('Recent Events');
|
||||
});
|
||||
|
||||
it('calls runtime with structured payload', async () => {
|
||||
const extractor = new UserPersonaExtractor(extractorConfig);
|
||||
await extractor.ensurePromptTemplate();
|
||||
|
||||
runtimeMock.generateObject = vi.fn().mockResolvedValue([
|
||||
{
|
||||
arguments: JSON.stringify({
|
||||
diff: '- updated',
|
||||
memoryIds: ['mem-1'],
|
||||
reasoning: 'why',
|
||||
sourceIds: ['src-1'],
|
||||
persona: '# Persona',
|
||||
tagline: 'pithy',
|
||||
}),
|
||||
name: 'commit_user_persona',
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await extractor.toolCall(templateOptions);
|
||||
|
||||
expect(result.persona).toBe('# Persona');
|
||||
expect(runtimeMock.generateObject).toHaveBeenCalledTimes(1);
|
||||
|
||||
const call = (runtimeMock.generateObject as any).mock.calls[0][0];
|
||||
expect(call.model).toBe('gpt-mock');
|
||||
expect(call.messages[0].content).toBe(
|
||||
renderPlaceholderTemplate(userPersonaPrompt, {
|
||||
language: 'English',
|
||||
topK: 10,
|
||||
username: 'User',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
141
packages/memory-user-memory/src/extractors/persona.ts
Normal file
141
packages/memory-user-memory/src/extractors/persona.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { renderPlaceholderTemplate } from '@lobechat/context-engine';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { userPersonaPrompt } from '../prompts';
|
||||
import {
|
||||
PersonaExtractorOptions,
|
||||
PersonaTemplateProps,
|
||||
UserPersonaExtractionResult,
|
||||
} from '../types';
|
||||
import { BaseMemoryExtractor } from './base';
|
||||
|
||||
const resultSchema = z.object({
|
||||
diff: z.string().optional(),
|
||||
memoryIds: z.array(z.string()).optional(),
|
||||
persona: z.string(),
|
||||
reasoning: z.string().optional(),
|
||||
sourceIds: z.array(z.string()).optional(),
|
||||
tagline: z.string().optional(),
|
||||
});
|
||||
|
||||
export class UserPersonaExtractor extends BaseMemoryExtractor<
|
||||
UserPersonaExtractionResult,
|
||||
PersonaTemplateProps,
|
||||
PersonaExtractorOptions
|
||||
> {
|
||||
getPrompt() {
|
||||
return userPersonaPrompt;
|
||||
}
|
||||
|
||||
getResultSchema() {
|
||||
return resultSchema;
|
||||
}
|
||||
|
||||
protected getPromptName(): string {
|
||||
return 'user-persona';
|
||||
}
|
||||
|
||||
// Use tool-calling instead of JSON schema for richer arguments parsing.
|
||||
protected getSchema(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
protected getTools(_options: PersonaTemplateProps) {
|
||||
return [
|
||||
{
|
||||
function: {
|
||||
description:
|
||||
'Persist an updated user persona document that summarizes the user, preferences, relationships, and recent events.',
|
||||
name: 'commit_user_persona',
|
||||
parameters: {
|
||||
properties: {
|
||||
diff: {
|
||||
description: 'Bullet list of changes applied this run',
|
||||
type: 'string',
|
||||
},
|
||||
memoryIds: {
|
||||
description: 'Related memory IDs used to craft the persona',
|
||||
items: { type: 'string' },
|
||||
type: 'array',
|
||||
},
|
||||
persona: { description: 'Complete Markdown persona for the user', type: 'string' },
|
||||
reasoning: {
|
||||
description: 'Why these changes were applied',
|
||||
type: 'string',
|
||||
},
|
||||
sourceIds: {
|
||||
description:
|
||||
'Source IDs (topic ID, document ID, or anything related) tied to this update',
|
||||
items: { type: 'string' },
|
||||
type: 'array',
|
||||
},
|
||||
tagline: {
|
||||
description: 'Short one-liner/tagline that captures the persona',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['persona'],
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
type: 'function' as const,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
buildUserPrompt(options: PersonaTemplateProps): string {
|
||||
const sections = [
|
||||
'## Existing Persona (baseline)',
|
||||
options.existingPersona?.trim() || 'No existing persona provided.',
|
||||
'## Retrieved Memories / Signals',
|
||||
options.retrievedMemories?.trim() || 'N/A',
|
||||
'## Recent Events or Highlights',
|
||||
options.recentEvents?.trim() || 'N/A',
|
||||
'## User Provided Notes or Requests',
|
||||
options.personaNotes?.trim() || 'N/A',
|
||||
'## Extra Profile Context',
|
||||
options.userProfile?.trim() || 'N/A',
|
||||
];
|
||||
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
|
||||
async toolCall(options?: PersonaExtractorOptions): Promise<UserPersonaExtractionResult> {
|
||||
await this.ensurePromptTemplate();
|
||||
|
||||
const systemPrompt = renderPlaceholderTemplate(
|
||||
this.promptTemplate || '',
|
||||
this.getTemplateProps(options || {}),
|
||||
);
|
||||
const userPrompt = this.buildUserPrompt(options || {});
|
||||
|
||||
const messages = [
|
||||
{ content: systemPrompt, role: 'system' as const },
|
||||
...((options?.additionalMessages || []) as any),
|
||||
{ content: userPrompt, role: 'user' as const },
|
||||
];
|
||||
|
||||
const result = (await this.runtime.generateObject({
|
||||
messages,
|
||||
model: this.model,
|
||||
tools: this.getTools(options || {}),
|
||||
})) as unknown;
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
const firstCall = result[0];
|
||||
const args =
|
||||
typeof firstCall?.arguments === 'string'
|
||||
? JSON.parse(firstCall.arguments || '{}')
|
||||
: firstCall?.arguments;
|
||||
|
||||
return resultSchema.parse(args || {});
|
||||
}
|
||||
|
||||
return resultSchema.parse(result);
|
||||
}
|
||||
|
||||
async structuredCall(options?: PersonaExtractorOptions): Promise<UserPersonaExtractionResult> {
|
||||
return this.toolCall(options);
|
||||
}
|
||||
}
|
||||
@@ -6,3 +6,4 @@ export {
|
||||
identityPrompt,
|
||||
preferencePrompt,
|
||||
} from './layers';
|
||||
export { userPersonaPrompt } from './persona';
|
||||
|
||||
60
packages/memory-user-memory/src/prompts/persona.ts
Normal file
60
packages/memory-user-memory/src/prompts/persona.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// TODO(@nekomeowww): introduce profile when multi-persona is enabled.
|
||||
export const userPersonaPrompt = `
|
||||
You are the dedicated **User Persona Curator** agent for Lobe AI.
|
||||
Write directly to the user in second person ("you") with a natural, non-deterministic voice.
|
||||
Describe the user like a thoughtful biographer who wants them to enjoy reading about themselves—blend facts into narrative sentences, let it feel warm, observant, and human, and keep the outline clear.
|
||||
Your job is to maintain a well-structured Markdown persona that captures how you understand {{ username }} and how to describe them.
|
||||
|
||||
### Coverage
|
||||
|
||||
- Identity and roles (work, school, communities, family roles if stated).
|
||||
- What you care about and do (interests, preferences, motivations, goals).
|
||||
- Current focus areas and ongoing efforts.
|
||||
- Recent events and milestones worth remembering (add month/year when known; anchor relative time to the message timestamp or sessionDate) written as concise story beats, not bullet logs.
|
||||
- Important people and relationships (names/roles/context when stated; do not guess).
|
||||
- Work/school context (team, domain, stage; if unclear, state it is unclear).
|
||||
- Emotional or interaction cues the user has shared (tone they like, pacing, what they appreciate or dislike).
|
||||
- Risks, blockers, open questions to watch.
|
||||
|
||||
### Structure
|
||||
|
||||
- Start with a short one-liner or tagline that feels true to the current persona (keep it punchy and human).
|
||||
- Organize the Markdown with clear headings (for example: Identity, What you care about, Current focus, Recent highlights, Relationships, Work/School, Interaction cues, Goals and risks).
|
||||
- Within each heading, use 1-4 narrative sentences that read like a story about the user; avoid raw lists unless they sharpen clarity.
|
||||
- Keep sections flexible: add new headings when needed; skip ones with no signal instead of inventing content.
|
||||
|
||||
### Refresh Rules
|
||||
|
||||
- Always write in {{ language }}.
|
||||
- Start from the existing persona when provided; merge new information rather than rewriting everything.
|
||||
- Keep it concise but vivid: aim for about 400-3000 words; go longer only when real detail exists (never pad or repeat).
|
||||
- Synthesize signals into abstractions and themes; do not dump raw memory snippets or line-by-line events.
|
||||
- Vary phrasing to avoid repetition; keep it grounded in observed facts.
|
||||
- Do not fabricate: if a detail is unknown (for example, family role or job title), say it is unclear and invite the user to share more.
|
||||
- Prefer explicit names over pronouns; avoid guessing genders or honorifics.
|
||||
- If a section lacks signal (for example relationships or team context), say explicitly that you need more detail and invite the user to fill it in; do not invent or over-index on thin clues. Do it in a kind, reader-friendly line that keeps the flow.
|
||||
- Never surface internal IDs (memoryIds, sourceIds, database IDs) or raw file paths inside the persona, tagline, diff, or reasoning; keep identifiers only in the JSON fields meant for them.
|
||||
|
||||
### Output Format (JSON object)
|
||||
|
||||
{
|
||||
"tagline": "<short one-liner/tagline that captures the persona>",
|
||||
"persona": "<updated markdown persona with headings>",
|
||||
"diff": "<short Markdown changelog describing what changed; mention sections touched>",
|
||||
"reasoning": "<why these updates were made>",
|
||||
"memoryIds": ["<related user_memory ids>"],
|
||||
"sourceIds": ["<source ids or topic ids tied to these updates>"]
|
||||
}
|
||||
|
||||
- diff should be human-readable (bullet list), not a patch; include section names touched.
|
||||
- Leave arrays empty when unknown; do not invent IDs.
|
||||
- Escape newlines and ensure the JSON is valid.
|
||||
|
||||
### Inputs Provided
|
||||
|
||||
- Existing persona (if any): treat as the baseline state.
|
||||
- Retrieved memories and signals: use them to ground updates and keep the persona consistent.
|
||||
- Recent events or user-provided notes: fold them into the appropriate sections and date-stamp when possible.
|
||||
|
||||
Return only valid JSON following the schema above.
|
||||
`;
|
||||
@@ -19,7 +19,8 @@ export type MemoryExtractionAgent =
|
||||
| 'layer-context'
|
||||
| 'layer-experience'
|
||||
| 'layer-identity'
|
||||
| 'layer-preference';
|
||||
| 'layer-preference'
|
||||
| 'user-persona';
|
||||
|
||||
export interface ExtractorRunOptions<RO> extends ExtractorOptions {
|
||||
contextProvider: MemoryContextProvider<{ topK?: number }>;
|
||||
@@ -177,3 +178,22 @@ export interface MemoryExtractionResult {
|
||||
export interface TemplateProps {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PersonaTemplateProps extends ExtractorTemplateProps {
|
||||
existingPersona?: string;
|
||||
personaNotes?: string;
|
||||
recentEvents?: string;
|
||||
retrievedMemories?: string;
|
||||
userProfile?: string;
|
||||
}
|
||||
|
||||
export interface PersonaExtractorOptions extends ExtractorOptions, PersonaTemplateProps {}
|
||||
|
||||
export interface UserPersonaExtractionResult {
|
||||
diff?: string | null;
|
||||
memoryIds?: string[];
|
||||
persona: string;
|
||||
reasoning?: string | null;
|
||||
sourceIds?: string[];
|
||||
tagline?: string | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getServerDB } from '@/database/server';
|
||||
import { parseMemoryExtractionConfig } from '@/server/globalConfig/parseMemoryExtractionConfig';
|
||||
import { MemoryExtractionWorkflowService } from '@/server/services/memory/userMemory/extract';
|
||||
import {
|
||||
UserPersonaService,
|
||||
buildUserPersonaJobInput,
|
||||
} from '@/server/services/memory/userMemory/persona/service';
|
||||
|
||||
const userPersonaWebhookSchema = z.object({
|
||||
baseUrl: z.string().url().optional(),
|
||||
mode: z.enum(['workflow', 'direct']).optional(),
|
||||
userId: z.string().optional(),
|
||||
userIds: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
type UserPersonaWebhookPayload = z.infer<typeof userPersonaWebhookSchema>;
|
||||
|
||||
const normalizeUserPersonaPayload = (
|
||||
payload: UserPersonaWebhookPayload,
|
||||
fallbackBaseUrl?: string,
|
||||
) => {
|
||||
const parsed = userPersonaWebhookSchema.parse(payload);
|
||||
const baseUrl = parsed.baseUrl || fallbackBaseUrl;
|
||||
|
||||
if (!baseUrl) throw new Error('Missing baseUrl for workflow trigger');
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
mode: parsed.mode ?? 'workflow',
|
||||
userIds: Array.from(
|
||||
new Set([...(parsed.userIds || []), ...(parsed.userId ? [parsed.userId] : [])]),
|
||||
).filter(Boolean),
|
||||
} as const;
|
||||
};
|
||||
|
||||
export const POST = async (req: Request) => {
|
||||
const { upstashWorkflowExtraHeaders, webhook } = parseMemoryExtractionConfig();
|
||||
|
||||
if (webhook.headers && Object.keys(webhook.headers).length > 0) {
|
||||
for (const [key, value] of Object.entries(webhook.headers)) {
|
||||
const headerValue = req.headers.get(key);
|
||||
if (headerValue !== value) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unauthorized: Missing or invalid header '${key}'` },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const json = await req.json();
|
||||
const origin = new URL(req.url).origin;
|
||||
const params = normalizeUserPersonaPayload(json, webhook.baseUrl || origin);
|
||||
|
||||
if (params.userIds.length === 0) {
|
||||
return NextResponse.json({ error: 'userId or userIds is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (params.mode === 'workflow') {
|
||||
const results = await Promise.all(
|
||||
params.userIds.map(async (userId) => {
|
||||
const { workflowRunId } = await MemoryExtractionWorkflowService.triggerPersonaUpdate(
|
||||
userId,
|
||||
params.baseUrl,
|
||||
{ extraHeaders: upstashWorkflowExtraHeaders },
|
||||
);
|
||||
|
||||
return { userId, workflowRunId };
|
||||
}),
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'User persona update scheduled via workflow.', results },
|
||||
{ status: 202 },
|
||||
);
|
||||
}
|
||||
|
||||
const db = await getServerDB();
|
||||
|
||||
const service = new UserPersonaService(db);
|
||||
const results = [];
|
||||
|
||||
for (const userId of params.userIds) {
|
||||
const context = await buildUserPersonaJobInput(db, userId);
|
||||
const result = await service.composeWriting({ ...context, userId });
|
||||
results.push({ userId, ...result });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'User persona generated via webhook.', results },
|
||||
{ status: 200 },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[user-persona] failed', error);
|
||||
|
||||
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -14,10 +14,16 @@ import {
|
||||
type MemoryExtractionPayloadInput,
|
||||
normalizeMemoryExtractionPayload,
|
||||
} from '@/server/services/memory/userMemory/extract';
|
||||
import { MemoryExtractionWorkflowService } from '@/server/services/memory/userMemory/extract';
|
||||
|
||||
const { upstashWorkflowExtraHeaders } = parseMemoryExtractionConfig();
|
||||
|
||||
const CEP_LAYERS: LayersEnum[] = [LayersEnum.Context, LayersEnum.Experience, LayersEnum.Preference];
|
||||
const CEPA_LAYERS: LayersEnum[] = [
|
||||
LayersEnum.Context,
|
||||
LayersEnum.Experience,
|
||||
LayersEnum.Preference,
|
||||
LayersEnum.Activity,
|
||||
];
|
||||
const IDENTITY_LAYERS: LayersEnum[] = [LayersEnum.Identity];
|
||||
|
||||
export const { POST } = serve<MemoryExtractionPayloadInput>(
|
||||
@@ -75,18 +81,24 @@ export const { POST } = serve<MemoryExtractionPayloadInput>(
|
||||
const userId = payload.userIds[0];
|
||||
const executor = await MemoryExtractionExecutor.create();
|
||||
|
||||
// CEP: run in parallel across the batch
|
||||
// CEPA: run in parallel across the batch
|
||||
//
|
||||
// NOTICE: if modified the parallelism of CEPA_LAYERS
|
||||
// or added new memory layer, make sure to update the number below.
|
||||
//
|
||||
// Currently, CEPA (context, experience, preference, activity) + identity = 5 layers.
|
||||
// and since identity requires sequential processing, we set parallelism to 5.
|
||||
await Promise.all(
|
||||
payload.topicIds.map((topicId, index) =>
|
||||
context.run(
|
||||
`memory:user-memory:extract:users:${userId}:topics:${topicId}:cep:${index}`,
|
||||
`memory:user-memory:extract:users:${userId}:topics:${topicId}:cepa:${index}`,
|
||||
() =>
|
||||
executor.extractTopic({
|
||||
asyncTaskId: payload.asyncTaskId,
|
||||
forceAll: payload.forceAll,
|
||||
forceTopics: payload.forceTopics,
|
||||
from: payload.from,
|
||||
layers: CEP_LAYERS,
|
||||
layers: CEPA_LAYERS,
|
||||
source: MemorySourceType.ChatTopic,
|
||||
to: payload.to,
|
||||
topicId,
|
||||
@@ -121,6 +133,13 @@ export const { POST } = serve<MemoryExtractionPayloadInput>(
|
||||
processedTopics: payload.topicIds.length,
|
||||
});
|
||||
|
||||
// Trigger user persona update after topic processing using the workflow client.
|
||||
await context.run(`memory:user-memory:users:${userId}`, async () => {
|
||||
await MemoryExtractionWorkflowService.triggerPersonaUpdate(userId, payload.baseUrl, {
|
||||
extraHeaders: upstashWorkflowExtraHeaders,
|
||||
});
|
||||
});
|
||||
|
||||
span.setStatus({ code: SpanStatusCode.OK });
|
||||
|
||||
return {
|
||||
|
||||
@@ -105,6 +105,7 @@ export const { POST } = serve<MemoryExtractionPayloadInput>(
|
||||
`memory:user-memory:extract:users:${userId}:process-topics-batch:${batchIndex}`,
|
||||
() =>
|
||||
MemoryExtractionWorkflowService.triggerProcessTopics(
|
||||
userId,
|
||||
{
|
||||
...buildWorkflowPayloadInput(params),
|
||||
topicCursor: undefined,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { serve } from '@upstash/workflow/nextjs';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getServerDB } from '@/database/server';
|
||||
import {
|
||||
UserPersonaService,
|
||||
buildUserPersonaJobInput,
|
||||
} from '@/server/services/memory/userMemory/persona/service';
|
||||
|
||||
const workflowPayloadSchema = z.object({
|
||||
userId: z.string().optional(),
|
||||
userIds: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const { POST } = serve(async (context) => {
|
||||
const payload = workflowPayloadSchema.parse(context.requestPayload || {});
|
||||
const db = await getServerDB();
|
||||
|
||||
const userIds = Array.from(
|
||||
new Set([...(payload.userIds || []), ...(payload.userId ? [payload.userId] : [])]),
|
||||
).filter(Boolean);
|
||||
|
||||
if (userIds.length === 0) {
|
||||
return { message: 'userId or userIds is required', processedUsers: 0 };
|
||||
}
|
||||
|
||||
const service = new UserPersonaService(db);
|
||||
const results = [];
|
||||
|
||||
for (const userId of userIds) {
|
||||
const context = await buildUserPersonaJobInput(db, userId);
|
||||
const result = await service.composeWriting({ ...context, userId });
|
||||
results.push({
|
||||
diffId: result.diff?.id,
|
||||
documentId: result.document.id,
|
||||
userId,
|
||||
version: result.document.version,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
message: 'User persona processed via workflow.',
|
||||
processedUsers: userIds.length,
|
||||
results,
|
||||
};
|
||||
});
|
||||
@@ -30,6 +30,7 @@ export type MemoryAgentConfig = MemoryAgentPublicConfig & {
|
||||
language?: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
export type MemoryLayerExtractorConfig = MemoryLayerExtractorPublicConfig &
|
||||
MemoryAgentConfig & {
|
||||
layers: Record<GlobalMemoryLayer, string>;
|
||||
@@ -42,6 +43,7 @@ export interface MemoryExtractionPrivateConfig {
|
||||
agentLayerExtractor: MemoryLayerExtractorConfig;
|
||||
agentLayerExtractorPreferredModels?: string[];
|
||||
agentLayerExtractorPreferredProviders?: string[];
|
||||
agentPersonaWriter: MemoryAgentConfig;
|
||||
concurrency?: number;
|
||||
embedding: MemoryAgentConfig;
|
||||
embeddingPreferredModels?: string[];
|
||||
@@ -136,6 +138,26 @@ const parseEmbeddingAgent = (
|
||||
};
|
||||
};
|
||||
|
||||
const parsePersonaWriterAgent = (
|
||||
fallbackModel: string,
|
||||
fallbackProvider?: string,
|
||||
fallbackApiKey?: string,
|
||||
): MemoryAgentConfig => {
|
||||
const model = process.env.MEMORY_USER_MEMORY_PERSONA_WRITER_MODEL || fallbackModel;
|
||||
const provider =
|
||||
process.env.MEMORY_USER_MEMORY_PERSONA_WRITER_PROVIDER ||
|
||||
fallbackProvider ||
|
||||
DEFAULT_MINI_PROVIDER;
|
||||
|
||||
return {
|
||||
apiKey: process.env.MEMORY_USER_MEMORY_PERSONA_WRITER_API_KEY ?? fallbackApiKey,
|
||||
baseURL: process.env.MEMORY_USER_MEMORY_PERSONA_WRITER_BASE_URL,
|
||||
contextLimit: parseTokenLimitEnv(process.env.MEMORY_USER_MEMORY_PERSONA_WRITER_CONTEXT_LIMIT),
|
||||
model,
|
||||
provider,
|
||||
};
|
||||
};
|
||||
|
||||
const parseExtractorAgentObservabilityS3 = () => {
|
||||
const accessKeyId = process.env.MEMORY_USER_MEMORY_EXTRACTOR_S3_ACCESS_KEY_ID;
|
||||
const secretAccessKey = process.env.MEMORY_USER_MEMORY_EXTRACTOR_S3_SECRET_ACCESS_KEY;
|
||||
@@ -180,6 +202,7 @@ const parsePreferredList = (value?: string) =>
|
||||
export const parseMemoryExtractionConfig = (): MemoryExtractionPrivateConfig => {
|
||||
const agentGateKeeper = parseGateKeeperAgent();
|
||||
const agentLayerExtractor = parseLayerExtractorAgent(agentGateKeeper.model);
|
||||
const agentPersonaWriter = parsePersonaWriterAgent(agentGateKeeper.model);
|
||||
const embedding = parseEmbeddingAgent(
|
||||
agentLayerExtractor.model,
|
||||
agentLayerExtractor.provider || DEFAULT_MINI_PROVIDER,
|
||||
@@ -249,6 +272,7 @@ export const parseMemoryExtractionConfig = (): MemoryExtractionPrivateConfig =>
|
||||
agentLayerExtractor,
|
||||
agentLayerExtractorPreferredModels,
|
||||
agentLayerExtractorPreferredProviders,
|
||||
agentPersonaWriter,
|
||||
concurrency,
|
||||
embedding,
|
||||
embeddingPreferredModels,
|
||||
|
||||
@@ -35,6 +35,7 @@ const createExecutor = (privateOverrides?: Partial<MemoryExtractionPrivateConfig
|
||||
model: 'layer-1',
|
||||
provider: 'provider-l',
|
||||
},
|
||||
agentPersonaWriter: { model: 'persona-1', provider: 'provider-s' },
|
||||
concurrency: 1,
|
||||
embedding: { model: 'embed-1', provider: 'provider-e' },
|
||||
featureFlags: { enableBenchmarkLoCoMo: false },
|
||||
|
||||
@@ -45,6 +45,7 @@ import type {
|
||||
MemoryExtractionTraceError,
|
||||
MemoryExtractionTracePayload,
|
||||
} from '@lobechat/types';
|
||||
import { FlowControl } from '@upstash/qstash';
|
||||
import { Client } from '@upstash/workflow';
|
||||
import debug from 'debug';
|
||||
import { and, asc, eq, inArray } from 'drizzle-orm';
|
||||
@@ -76,6 +77,7 @@ import {
|
||||
type MergeStrategyEnum,
|
||||
TypesEnum,
|
||||
} from '@/types/userMemory';
|
||||
import { trimBasedOnBatchProbe } from '@/utils/chunkers';
|
||||
import { encodeAsync } from '@/utils/tokenizer';
|
||||
|
||||
const SOURCE_ALIAS_MAP: Record<string, MemorySourceType> = {
|
||||
@@ -468,13 +470,8 @@ export class MemoryExtractionExecutor {
|
||||
return await encodeAsync(normalized);
|
||||
}
|
||||
|
||||
private trimTextToTokenLimit(text: string, tokenLimit?: number) {
|
||||
if (!tokenLimit || tokenLimit <= 0) return text;
|
||||
|
||||
const tokens = text.split(/\s+/);
|
||||
if (tokens.length <= tokenLimit) return text;
|
||||
|
||||
return tokens.slice(Math.max(tokens.length - tokenLimit, 0)).join(' ');
|
||||
private async trimTextToTokenLimit(text: string, tokenLimit?: number) {
|
||||
return trimBasedOnBatchProbe(text, tokenLimit);
|
||||
}
|
||||
|
||||
private async trimConversationsToTokenLimit<T extends OpenAIChatMessage>(
|
||||
@@ -504,7 +501,7 @@ export class MemoryExtractionExecutor {
|
||||
|
||||
const trimmedContent =
|
||||
typeof conversation.content === 'string'
|
||||
? this.trimTextToTokenLimit(conversation.content, remaining)
|
||||
? await this.trimTextToTokenLimit(conversation.content, remaining)
|
||||
: conversation.content;
|
||||
|
||||
if (trimmedContent && remaining > 0) {
|
||||
@@ -531,16 +528,15 @@ export class MemoryExtractionExecutor {
|
||||
};
|
||||
|
||||
return tracer.startActiveSpan('gen_ai.embed', { attributes }, async (span) => {
|
||||
const requests = texts
|
||||
.map((text, index) => {
|
||||
if (typeof text !== 'string') return null;
|
||||
const requests: { index: number; text: string }[] = [];
|
||||
for (const [index, text] of texts.entries()) {
|
||||
if (typeof text !== 'string') continue;
|
||||
|
||||
const trimmed = this.trimTextToTokenLimit(text, tokenLimit);
|
||||
if (!trimmed.trim()) return null;
|
||||
const trimmed = await this.trimTextToTokenLimit(text, tokenLimit);
|
||||
if (!trimmed.trim()) continue;
|
||||
|
||||
return { index, text: trimmed };
|
||||
})
|
||||
.filter(Boolean);
|
||||
requests.push({ index, text: trimmed });
|
||||
}
|
||||
|
||||
span.setAttribute('memory.embedding.text_count', texts.length);
|
||||
span.setAttribute('memory.embedding.request_count', requests.length);
|
||||
@@ -555,7 +551,7 @@ export class MemoryExtractionExecutor {
|
||||
const response = await runtimes.embeddings(
|
||||
{
|
||||
dimensions: DEFAULT_USER_MEMORY_EMBEDDING_DIMENSIONS,
|
||||
input: requests.map((item) => item!.text),
|
||||
input: requests.map((item) => item.text),
|
||||
model,
|
||||
},
|
||||
{ user: 'memory-extraction' },
|
||||
@@ -1000,7 +996,7 @@ export class MemoryExtractionExecutor {
|
||||
const userMemoryModel = new UserMemoryModel(db, userId);
|
||||
// TODO: make topK configurable
|
||||
const topK = 10;
|
||||
const aggregatedContent = this.trimTextToTokenLimit(
|
||||
const aggregatedContent = await this.trimTextToTokenLimit(
|
||||
conversations.map((msg) => `${msg.role.toUpperCase()}: ${msg.content}`).join('\n\n'),
|
||||
tokenLimit,
|
||||
);
|
||||
@@ -1227,11 +1223,12 @@ export class MemoryExtractionExecutor {
|
||||
extractionJob.userId,
|
||||
extractionJob.sourceId,
|
||||
);
|
||||
const trimmedRetrievedContexts = [
|
||||
topicContext.context,
|
||||
retrievalMemoryContext.context,
|
||||
].map((context) => this.trimTextToTokenLimit(context, extractorContextLimit));
|
||||
const trimmedRetrievedIdentitiesContext = this.trimTextToTokenLimit(
|
||||
const trimmedRetrievedContexts = await Promise.all(
|
||||
[topicContext.context, retrievalMemoryContext.context].map((context) =>
|
||||
this.trimTextToTokenLimit(context, extractorContextLimit),
|
||||
),
|
||||
);
|
||||
const trimmedRetrievedIdentitiesContext = await this.trimTextToTokenLimit(
|
||||
retrievedIdentityContext.context,
|
||||
extractorContextLimit,
|
||||
);
|
||||
@@ -2022,7 +2019,7 @@ export class MemoryExtractionExecutor {
|
||||
|
||||
const builtContext = await contextProvider.buildContext(extractionJob.userId);
|
||||
const extractorContextLimit = this.privateConfig.agentLayerExtractor.contextLimit;
|
||||
const trimmedContext = this.trimTextToTokenLimit(
|
||||
const trimmedContext = await this.trimTextToTokenLimit(
|
||||
builtContext.context,
|
||||
extractorContextLimit,
|
||||
);
|
||||
@@ -2144,6 +2141,7 @@ export class MemoryExtractionExecutor {
|
||||
}
|
||||
|
||||
const WORKFLOW_PATHS = {
|
||||
personaUpdate: '/api/workflows/memory-user-memory/pipelines/persona/update-writing',
|
||||
topicBatch: '/api/workflows/memory-user-memory/pipelines/chat-topic/process-topics',
|
||||
userTopics: '/api/workflows/memory-user-memory/pipelines/chat-topic/process-user-topics',
|
||||
users: '/api/workflows/memory-user-memory/pipelines/chat-topic/process-users',
|
||||
@@ -2200,10 +2198,15 @@ export class MemoryExtractionWorkflowService {
|
||||
}
|
||||
|
||||
const url = getWorkflowUrl(WORKFLOW_PATHS.userTopics, payload.baseUrl);
|
||||
return this.getClient().trigger({ body: payload, headers: options?.extraHeaders, url });
|
||||
return this.getClient().trigger({
|
||||
body: payload,
|
||||
headers: options?.extraHeaders,
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
static triggerProcessTopics(
|
||||
userId: string,
|
||||
payload: MemoryExtractionPayloadInput,
|
||||
options?: { extraHeaders?: Record<string, string> },
|
||||
) {
|
||||
@@ -2212,6 +2215,41 @@ export class MemoryExtractionWorkflowService {
|
||||
}
|
||||
|
||||
const url = getWorkflowUrl(WORKFLOW_PATHS.topicBatch, payload.baseUrl);
|
||||
return this.getClient().trigger({ body: payload, headers: options?.extraHeaders, url });
|
||||
return this.getClient().trigger({
|
||||
body: payload,
|
||||
flowControl: {
|
||||
key: `memory-user-memory:pipelines:chat-topic:process-topics:user:${userId}`,
|
||||
// NOTICE: if modified the parallelism of
|
||||
// src/app/(backend)/api/workflows/memory-user-memory/pipelines/chat-topic/process-topics/route.ts
|
||||
// or added new memory layer, make sure to update the number below.
|
||||
//
|
||||
// Currently, CEPA (context, experience, preference, activity) + identity = 5 layers.
|
||||
// and since identity requires sequential processing, we set parallelism to 5.
|
||||
parallelism: 5,
|
||||
},
|
||||
headers: options?.extraHeaders,
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
static triggerPersonaUpdate(
|
||||
userId: string,
|
||||
baseUrl: string,
|
||||
options?: { extraHeaders?: Record<string, string> },
|
||||
) {
|
||||
if (!baseUrl) {
|
||||
throw new Error('Missing baseUrl for workflow trigger');
|
||||
}
|
||||
|
||||
const url = getWorkflowUrl(WORKFLOW_PATHS.personaUpdate, baseUrl);
|
||||
return this.getClient().trigger({
|
||||
body: { userId: [userId] },
|
||||
flowControl: {
|
||||
key: 'memory-user-memory:pipelines:persona:update-write:' + userId,
|
||||
parallelism: 1,
|
||||
} satisfies FlowControl,
|
||||
headers: options?.extraHeaders,
|
||||
url,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// @vitest-environment node
|
||||
import { LobeChatDatabase } from '@lobechat/database';
|
||||
import { users } from '@lobechat/database/schemas';
|
||||
import { getTestDB } from '@lobechat/database/test-utils';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { UserPersonaModel } from '@/database/models/userMemory/persona';
|
||||
|
||||
import { UserPersonaService } from '../service';
|
||||
|
||||
vi.mock('@/server/globalConfig/parseMemoryExtractionConfig', () => ({
|
||||
parseMemoryExtractionConfig: () => ({
|
||||
agentLayerExtractor: {
|
||||
apiKey: 'test-key',
|
||||
baseURL: 'https://example.com',
|
||||
language: 'English',
|
||||
layers: { context: 'gpt-mock' },
|
||||
model: 'gpt-mock',
|
||||
provider: 'openai',
|
||||
},
|
||||
agentPersonaWriter: {
|
||||
apiKey: 'test-key',
|
||||
baseURL: 'https://example.com',
|
||||
language: 'English',
|
||||
model: 'gpt-mock',
|
||||
provider: 'openai',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const structuredResult = {
|
||||
diff: '- updated',
|
||||
memoryIds: ['mem-1'],
|
||||
persona: '# Persona',
|
||||
reasoning: 'reason',
|
||||
sourceIds: ['src-1'],
|
||||
summary: 'summary',
|
||||
};
|
||||
|
||||
const toolCall = vi.fn().mockResolvedValue(structuredResult);
|
||||
|
||||
vi.mock('@lobechat/memory-user-memory', () => ({
|
||||
UserPersonaExtractor: vi.fn().mockImplementation(() => ({
|
||||
toolCall,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@lobechat/model-runtime', () => ({
|
||||
ModelRuntime: {
|
||||
initializeWithProvider: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}));
|
||||
|
||||
let db: LobeChatDatabase;
|
||||
const userId = 'user-persona-service';
|
||||
|
||||
beforeEach(async () => {
|
||||
toolCall.mockClear();
|
||||
db = await getTestDB();
|
||||
|
||||
await db.delete(users);
|
||||
await db.insert(users).values({ id: userId });
|
||||
});
|
||||
|
||||
describe('UserPersonaService', () => {
|
||||
it('composes and persists persona via agent', async () => {
|
||||
const service = new UserPersonaService(db);
|
||||
const result = await service.composeWriting({
|
||||
personaNotes: '- note',
|
||||
recentEvents: '- event',
|
||||
retrievedMemories: '- mem',
|
||||
userId,
|
||||
username: 'User',
|
||||
});
|
||||
|
||||
expect(toolCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
language: 'English',
|
||||
username: 'User',
|
||||
}),
|
||||
);
|
||||
expect(result.document.persona).toBe('# Persona');
|
||||
|
||||
const model = new UserPersonaModel(db, userId);
|
||||
const latest = await model.getLatestPersonaDocument();
|
||||
expect(latest?.version).toBe(1);
|
||||
});
|
||||
|
||||
it('passes existing persona baseline on subsequent runs', async () => {
|
||||
const service = new UserPersonaService(db);
|
||||
await service.composeWriting({ userId, username: 'User' });
|
||||
await service.composeWriting({ userId, username: 'User' });
|
||||
|
||||
expect(toolCall).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
existingPersona: '# Persona',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
157
src/server/services/memory/userMemory/persona/service.ts
Normal file
157
src/server/services/memory/userMemory/persona/service.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import type {
|
||||
UserPersonaDocument,
|
||||
UserPersonaDocumentHistoriesItem,
|
||||
} from '@lobechat/database/schemas';
|
||||
import { userMemories } from '@lobechat/database/schemas';
|
||||
import {
|
||||
RetrievalUserMemoryContextProvider,
|
||||
RetrievalUserMemoryIdentitiesProvider,
|
||||
type UserPersonaExtractionResult,
|
||||
UserPersonaExtractor,
|
||||
} from '@lobechat/memory-user-memory';
|
||||
import { ModelRuntime } from '@lobechat/model-runtime';
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
|
||||
import { UserMemoryModel } from '@/database/models/userMemory';
|
||||
import { UserPersonaModel } from '@/database/models/userMemory/persona';
|
||||
import { LobeChatDatabase } from '@/database/type';
|
||||
import {
|
||||
MemoryAgentConfig,
|
||||
parseMemoryExtractionConfig,
|
||||
} from '@/server/globalConfig/parseMemoryExtractionConfig';
|
||||
import { LayersEnum } from '@/types/userMemory';
|
||||
import { trimBasedOnBatchProbe } from '@/utils/chunkers';
|
||||
|
||||
interface UserPersonaAgentPayload {
|
||||
existingPersona?: string | null;
|
||||
language?: string;
|
||||
memoryIds?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
personaNotes?: string;
|
||||
recentEvents?: string;
|
||||
retrievedMemories?: string;
|
||||
sourceIds?: string[];
|
||||
userId: string;
|
||||
userProfile?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
interface UserPersonaAgentResult {
|
||||
agentResult: UserPersonaExtractionResult;
|
||||
diff?: UserPersonaDocumentHistoriesItem;
|
||||
document: UserPersonaDocument;
|
||||
}
|
||||
|
||||
export class UserPersonaService {
|
||||
private readonly preferredLanguage?: string;
|
||||
private readonly db: LobeChatDatabase;
|
||||
private readonly runtime: ModelRuntime;
|
||||
private readonly agentConfig: MemoryAgentConfig;
|
||||
|
||||
constructor(db: LobeChatDatabase) {
|
||||
const { agentPersonaWriter } = parseMemoryExtractionConfig();
|
||||
|
||||
this.db = db;
|
||||
this.preferredLanguage = agentPersonaWriter.language;
|
||||
this.agentConfig = agentPersonaWriter;
|
||||
this.runtime = ModelRuntime.initializeWithProvider(agentPersonaWriter.provider || 'openai', {
|
||||
apiKey: agentPersonaWriter.apiKey,
|
||||
baseURL: agentPersonaWriter.baseURL,
|
||||
});
|
||||
}
|
||||
|
||||
async composeWriting(payload: UserPersonaAgentPayload): Promise<UserPersonaAgentResult> {
|
||||
const personaModel = new UserPersonaModel(this.db, payload.userId);
|
||||
const lastDocument = await personaModel.getLatestPersonaDocument();
|
||||
const existingPersonaBaseline = payload.existingPersona ?? lastDocument?.persona;
|
||||
|
||||
const extractor = new UserPersonaExtractor({
|
||||
agent: 'user-persona',
|
||||
model: this.agentConfig.model,
|
||||
modelRuntime: this.runtime,
|
||||
});
|
||||
|
||||
const agentResult = await extractor.toolCall({
|
||||
existingPersona: existingPersonaBaseline || undefined,
|
||||
language: payload.language || this.preferredLanguage,
|
||||
personaNotes: payload.personaNotes,
|
||||
recentEvents: payload.recentEvents,
|
||||
retrievedMemories: payload.retrievedMemories,
|
||||
userProfile: payload.userProfile,
|
||||
username: payload.username,
|
||||
});
|
||||
|
||||
const persisted = await personaModel.upsertPersona({
|
||||
capturedAt: new Date(),
|
||||
diffPersona: agentResult.diff ?? undefined,
|
||||
editedBy: 'agent',
|
||||
memoryIds: payload.memoryIds ?? agentResult.memoryIds ?? undefined,
|
||||
metadata: payload.metadata ?? undefined,
|
||||
persona: agentResult.persona,
|
||||
reasoning: agentResult.reasoning ?? undefined,
|
||||
snapshot: agentResult.persona,
|
||||
sourceIds: payload.sourceIds ?? agentResult.sourceIds ?? undefined,
|
||||
tagline: agentResult.tagline ?? undefined,
|
||||
});
|
||||
|
||||
return { agentResult, ...persisted };
|
||||
}
|
||||
}
|
||||
|
||||
export const buildUserPersonaJobInput = async (db: LobeChatDatabase, userId: string) => {
|
||||
const personaModel = new UserPersonaModel(db, userId);
|
||||
const latestPersona = await personaModel.getLatestPersonaDocument();
|
||||
const { agentPersonaWriter } = parseMemoryExtractionConfig();
|
||||
const personaContextLimit = agentPersonaWriter.contextLimit;
|
||||
|
||||
const userMemoryModel = new UserMemoryModel(db, userId);
|
||||
|
||||
const [identities, activities, contexts, preferences, memories] = await Promise.all([
|
||||
userMemoryModel.getAllIdentitiesWithMemory(),
|
||||
// TODO(@nekomeowww): @arvinxx kindly take some time to review this policy
|
||||
userMemoryModel.listMemories({ layer: LayersEnum.Activity, pageSize: 3 }),
|
||||
userMemoryModel.listMemories({ layer: LayersEnum.Context, pageSize: 3 }),
|
||||
userMemoryModel.listMemories({ layer: LayersEnum.Preference, pageSize: 10 }),
|
||||
db.query.userMemories.findMany({
|
||||
limit: 20,
|
||||
orderBy: [desc(userMemories.capturedAt)],
|
||||
where: eq(userMemories.userId, userId),
|
||||
}),
|
||||
]);
|
||||
|
||||
const contextProvider = new RetrievalUserMemoryContextProvider({
|
||||
retrievedMemories: {
|
||||
activities: activities.map((a) => a.activity),
|
||||
contexts: contexts.map((c) => c.context),
|
||||
experiences: [],
|
||||
preferences: preferences.map((p) => p.preference),
|
||||
},
|
||||
});
|
||||
|
||||
const identityProvider = new RetrievalUserMemoryIdentitiesProvider({
|
||||
retrievedIdentities: identities.map((i) => ({
|
||||
...i,
|
||||
layer: LayersEnum.Identity,
|
||||
})),
|
||||
});
|
||||
|
||||
const [recentMemoriesContext, allIdentitiesContext] = await Promise.all([
|
||||
contextProvider.buildContext(userId, 'user-persona-memories'),
|
||||
identityProvider.buildContext(userId, 'user-persona-memories-identities'),
|
||||
]);
|
||||
|
||||
const rawContext = [recentMemoriesContext.context, allIdentitiesContext.context]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
|
||||
const trimmedContext = rawContext
|
||||
? await trimBasedOnBatchProbe(rawContext, personaContextLimit)
|
||||
: '';
|
||||
const assembledContext = trimmedContext?.trim();
|
||||
|
||||
return {
|
||||
existingPersona: latestPersona?.persona || undefined,
|
||||
memoryIds: memories.map((m) => m.id),
|
||||
retrievedMemories: assembledContext || undefined,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user