diff --git a/packages/backend/src/ai/ai.constants.ts b/packages/backend/src/ai/ai.constants.ts index cc697249..65ede081 100644 --- a/packages/backend/src/ai/ai.constants.ts +++ b/packages/backend/src/ai/ai.constants.ts @@ -56,22 +56,6 @@ unclear intent → make your best guess and commit. do not ask for clarification message doesn't need you → stay quiet. - -these demonstrate your best responses — vary the language but match the feel: - -factual (concise, conversational, specific): -- "short answer: no — they're different tools for different problems." -- "because windows + active directory gives enterprises centralized identity, device management, and legacy app support at massive scale. it's boring, deeply unsexy, and extremely reliable." -- "rsync + backblaze b2 is solid for unraid — cheap, reliable, and the plugin makes it pretty painless. duplicacy is worth a look too if you want versioning." - -taking a side (committed, specific, no hedging): -- "jr is more factually correct about how llms actually work, but neal is more correct about the moral pressure to keep improving safety." - -humor (situational, cutting, uses real details): -- "if you took mcdonalds napkins instead of buying them at the store for 25 years you'd probably save like $50 to $100 but you'd have to factor in the emotional cost of living like that for a quarter century." -- "yes — but in the deeply spiritual way only a man personally betrayed by a typescript union type can overreact." - - before sending any response, check: 1. does it start with the actual answer, not a name or greeting? @@ -91,23 +75,6 @@ export const MOONBEAM_SLACK_ID = 'ULG8SJRFF'; export const GATE_MODEL = 'gpt-4.1-nano'; -export const MEMORY_SELECTION_PROMPT = `You are selecting which stored memories are relevant to a conversation that is about to get a response. -You are NOT responding — you are picking useful context. - -STORED MEMORIES: -{all_memories_grouped_by_user} - -Return the IDs of memories that are relevant to what's being discussed, or that would enable: -- A callback to something someone said before -- Catching a contradiction or shift in position -- Playing into a known dynamic between people -- Adjusting tone based on how someone engages - -Return a JSON array of memory IDs: [1, 4, 17, 23] -Or return an empty array [] if nothing is relevant. - -Most conversations will need 0-5 memories. Do not force relevance where there is none.`; - export const MEMORY_EXTRACTION_PROMPT = `You are a memory extraction tool analyzing a Slack conversation. Your job is to identify notable observations about the people in this conversation that would be worth remembering for future interactions. @@ -162,4 +129,35 @@ Format: [{"slackId": "U12345", "content": "description of what they said or did" Keep each memory to 1-2 sentences. Be specific — include what was actually said, not a summary of the topic.`; +export const TRAIT_EXTRACTION_PROMPT = `You are a trait synthesis tool. + +You are given a set of stored memories about one specific user from a group chat. +Your task is to infer that user's most stable, high-signal traits and beliefs. + +Goal: +- Return up to 10 traits that capture enduring preferences, convictions, communication patterns, and relationship dynamics. +- Focus on traits that would actually help produce better future responses in a chat context. + +Prioritize traits like: +- clear preferences ("prefers TypeScript over Python") +- recurring beliefs or stances ("strongly anti-Trump") +- consistent social dynamics ("often challenges Moonbeam when it hedges") + +Do NOT include: +- one-off events +- low-signal trivia +- private/sensitive details (addresses, medical details, workplaces, family names) +- contradictions unless a new stance clearly replaced an old one + +Requirements: +- Traits must be concise, concrete, and attributable to the user. +- Write each trait in third person using their Slack ID placeholder if provided context supports it. +- No duplicates or near-duplicates. +- Prefer quality over quantity. If only 4 strong traits exist, return 4. + +Output format: +- Return ONLY a JSON array of strings. +- Example: ["JR-15 prefers TypeScript as his primary programming language", "JR-15 strongly dislikes Donald Trump"] +- If no strong traits are present, return []`; + export const DAILY_MEMORY_JOB_CONCURRENCY = 50; diff --git a/packages/backend/src/ai/ai.controller.spec.ts b/packages/backend/src/ai/ai.controller.spec.ts index 4a55f071..f0440310 100644 --- a/packages/backend/src/ai/ai.controller.spec.ts +++ b/packages/backend/src/ai/ai.controller.spec.ts @@ -12,6 +12,10 @@ const { generateText, generateImage, promptWithHistory, sendEphemeral, setCustom clearCustomPrompt: vi.fn().mockResolvedValue(true), })); +const { getAllTraitsForUser } = vi.hoisted(() => ({ + getAllTraitsForUser: vi.fn().mockResolvedValue([]), +})); + vi.mock('./ai.service', async () => ({ AIService: classMock(() => ({ generateText, @@ -28,6 +32,12 @@ vi.mock('../shared/services/web/web.service', async () => ({ })), })); +vi.mock('./trait/trait.persistence.service', async () => ({ + TraitPersistenceService: classMock(() => ({ + getAllTraitsForUser, + })), +})); + vi.mock('../shared/middleware/suppression', async () => ({ suppressedMiddleware: (_req: unknown, _res: unknown, next: () => void) => next(), })); @@ -51,6 +61,7 @@ describe('aiController', () => { vi.clearAllMocks(); setCustomPrompt.mockResolvedValue(true); clearCustomPrompt.mockResolvedValue(true); + getAllTraitsForUser.mockResolvedValue([]); }); it('handles /text', async () => { @@ -87,6 +98,48 @@ describe('aiController', () => { expect(sendEphemeral).toHaveBeenCalled(); }); + describe('/traits', () => { + it('returns immediate 200 and sends formatted traits ephemerally', async () => { + getAllTraitsForUser.mockResolvedValue([ + { + content: 'JR-15 prefers TypeScript as his programming language', + updatedAt: new Date('2026-04-01T00:00:00.000Z'), + }, + ]); + + await request(app).post('/traits').send({ user_id: 'U1', team_id: 'T1', channel_id: 'C1' }).expect(200); + + await Promise.resolve(); + + expect(getAllTraitsForUser).toHaveBeenCalledWith('U1', 'T1'); + expect(sendEphemeral).toHaveBeenCalledWith( + 'C1', + expect.stringContaining("Moonbeam's core traits about you:"), + 'U1', + ); + }); + + it('sends no-traits message when user has no traits', async () => { + getAllTraitsForUser.mockResolvedValue([]); + + await request(app).post('/traits').send({ user_id: 'U1', team_id: 'T1', channel_id: 'C1' }).expect(200); + + await Promise.resolve(); + + expect(sendEphemeral).toHaveBeenCalledWith('C1', "Moonbeam doesn't have any core traits about you yet.", 'U1'); + }); + + it('sends fallback error message when trait retrieval fails', async () => { + getAllTraitsForUser.mockRejectedValueOnce(new Error('db fail')); + + await request(app).post('/traits').send({ user_id: 'U1', team_id: 'T1', channel_id: 'C1' }).expect(200); + + await Promise.resolve(); + + expect(sendEphemeral).toHaveBeenCalledWith('C1', 'Sorry, something went wrong fetching your traits.', 'U1'); + }); + }); + describe('/set-prompt', () => { it('clears prompt when text is "clear"', async () => { const res = await request(app) diff --git a/packages/backend/src/ai/ai.controller.ts b/packages/backend/src/ai/ai.controller.ts index b3aad1b3..e40e6aa0 100644 --- a/packages/backend/src/ai/ai.controller.ts +++ b/packages/backend/src/ai/ai.controller.ts @@ -8,14 +8,55 @@ import { aiMiddleware } from './middleware/aiMiddleware'; import type { SlashCommandRequest } from '../shared/models/slack/slack-models'; import { logError } from '../shared/logger/error-logging'; import { logger } from '../shared/logger/logger'; +import { TraitPersistenceService } from './trait/trait.persistence.service'; export const aiController: Router = express.Router(); const webService = new WebService(); const aiService = new AIService(); +const traitPersistenceService = new TraitPersistenceService(); const aiLogger = logger.child({ module: 'AIController' }); aiController.use(suppressedMiddleware); + +aiController.post('/traits', (req, res) => { + const { user_id, team_id, channel_id } = req.body; + + // Respond immediately — Slack requires a response within 3 seconds + res.status(200).send(''); + + void (async () => { + try { + const traits = await traitPersistenceService.getAllTraitsForUser(user_id, team_id); + + if (traits.length === 0) { + void webService.sendEphemeral(channel_id, "Moonbeam doesn't have any core traits about you yet.", user_id); + return; + } + + const formattedTraits = traits + .map((trait, index) => { + const date = new Date(trait.updatedAt).toLocaleDateString('en-US', { + month: 'short', + year: 'numeric', + }); + return `${index + 1}. "${trait.content}" (${date.toLowerCase()})`; + }) + .join('\n'); + + const message = `Moonbeam's core traits about you:\n${formattedTraits}`; + void webService.sendEphemeral(channel_id, message, user_id); + } catch (e) { + logError(aiLogger, 'Failed to fetch traits for /ai/traits command', e, { + userId: user_id, + teamId: team_id, + channelId: channel_id, + }); + void webService.sendEphemeral(channel_id, 'Sorry, something went wrong fetching your traits.', user_id); + } + })(); +}); + aiController.use(textMiddleware); aiController.use(aiMiddleware); diff --git a/packages/backend/src/ai/ai.service.spec.ts b/packages/backend/src/ai/ai.service.spec.ts index a3fff8fa..e6ea99a6 100644 --- a/packages/backend/src/ai/ai.service.spec.ts +++ b/packages/backend/src/ai/ai.service.spec.ts @@ -37,11 +37,17 @@ const buildAiService = (): AIService => { ai.memoryPersistenceService = { getAllMemoriesForUsers: vi.fn().mockResolvedValue(new Map()), + getAllMemoriesForUser: vi.fn().mockResolvedValue([]), saveMemories: vi.fn().mockResolvedValue([]), reinforceMemory: vi.fn().mockResolvedValue(true), deleteMemory: vi.fn().mockResolvedValue(true), } as unknown as AIService['memoryPersistenceService']; + ai.traitPersistenceService = { + getAllTraitsForUsers: vi.fn().mockResolvedValue(new Map()), + replaceTraitsForUser: vi.fn().mockResolvedValue([]), + } as unknown as AIService['traitPersistenceService']; + ai.historyService = { getHistory: vi.fn().mockResolvedValue([]), getHistoryWithOptions: vi.fn().mockResolvedValue([]), @@ -339,6 +345,25 @@ describe('AIService', () => { expect.stringContaining('unable to send the requested text to Slack'), ); }); + + it('injects trait context when traits exist for participants', async () => { + (aiService.historyService.getHistory as Mock).mockResolvedValue([ + { name: 'Jane', slackId: 'U2', message: 'Hi there' }, + ]); + (aiService.traitPersistenceService.getAllTraitsForUsers as Mock).mockResolvedValue( + new Map([['U2', [{ slackId: 'U2', content: 'prefers typescript' }]]]), + ); + const createSpy = aiService.openAi.responses.create as Mock; + createSpy.mockResolvedValue({ + output: [{ type: 'message', content: [{ type: 'output_text', text: 'Response text' }] }], + }); + + await aiService.promptWithHistory({ user_id: 'U1', team_id: 'T1', channel_id: 'C1', text: 'Summarize' } as never); + + const callArgs = createSpy.mock.calls[0][0] as { instructions: string }; + expect(callArgs.instructions).toContain('traits_context'); + expect(callArgs.instructions).toContain('prefers typescript'); + }); }); describe('handle', () => { @@ -522,6 +547,25 @@ describe('AIService', () => { await expect(aiService.participate('T1', 'C1', 'hi')).rejects.toThrow('model fail'); expect(aiService.redis.removeParticipationInFlight).toHaveBeenCalledWith('C1', 'T1'); }); + + it('injects trait context for participation prompts', async () => { + (aiService.historyService.getHistoryWithOptions as Mock).mockResolvedValue([ + { slackId: 'U2', name: 'Jane', message: 'hello' }, + ]); + (aiService.traitPersistenceService.getAllTraitsForUsers as Mock).mockResolvedValue( + new Map([['U2', [{ slackId: 'U2', content: 'dislikes donald trump' }]]]), + ); + const createSpy = aiService.openAi.responses.create as Mock; + createSpy.mockResolvedValue({ + output: [{ type: 'message', content: [{ type: 'output_text', text: 'Participation response' }] }], + }); + + await aiService.participate('T1', 'C1', '<@moonbeam> hi'); + + const callArgs = createSpy.mock.calls[0][0] as { instructions: string }; + expect(callArgs.instructions).toContain('traits_context'); + expect(callArgs.instructions).toContain('dislikes donald trump'); + }); }); describe('participate with custom prompt', () => { @@ -687,27 +731,24 @@ describe('AIService', () => { }); }); - describe('memory helpers', () => { + describe('memory and trait helpers', () => { type AiServicePrivate = typeof aiService & { extractParticipantSlackIds: ( messages: Array<{ slackId: string; name: string; message: string }>, options: { includeSlackId?: string; excludeSlackIds?: string[] }, ) => string[]; - formatMemoryContext: ( - memories: Array<{ id: number; slackId: string; content: string }>, + formatTraitContext: ( + traits: Array<{ slackId: string; content: string }>, messages: Array<{ slackId: string; name: string; message: string }>, ) => string; - appendMemoryContext: (base: string, context: string) => string; - selectRelevantMemories: ( - conversation: string, - memoryMap: Map>, - ) => Promise; - fetchMemoryContext: ( + appendTraitContext: (base: string, context: string) => string; + fetchTraitContext: ( participantIds: string[], teamId: string, - conversation: string, messages: Array<{ slackId: string; name: string; message: string }>, ) => Promise; + parseTraitExtractionResult: (raw: string | undefined) => string[]; + regenerateTraitsForUsers: (teamId: string, slackIds: string[]) => Promise; extractMemories: (teamId: string, channelId: string, history: string, participantIds: string[]) => Promise; }; @@ -724,11 +765,11 @@ describe('AIService', () => { expect(ids).toEqual(['U2', 'U3']); }); - it('formats memory context grouped by participant', () => { - const text = (aiService as unknown as AiServicePrivate).formatMemoryContext( + it('formats trait context grouped by participant', () => { + const text = (aiService as unknown as AiServicePrivate).formatTraitContext( [ - { id: 1, slackId: 'U1', content: 'likes coffee' }, - { id: 2, slackId: 'U2', content: 'works on backend' }, + { slackId: 'U1', content: 'prefers typescript' }, + { slackId: 'U2', content: 'dislikes donald trump' }, ], [ { slackId: 'U1', name: 'Alice', message: 'hi' }, @@ -736,150 +777,70 @@ describe('AIService', () => { ], ); + expect(text).toContain('traits_context'); expect(text).toContain('Alice'); - expect(text).toContain('likes coffee'); + expect(text).toContain('prefers typescript'); expect(text).toContain('Bob'); }); - it('returns base instructions when no memory context', () => { - const result = (aiService as unknown as AiServicePrivate).appendMemoryContext('base', ''); + it('returns base instructions when no context', () => { + const result = (aiService as unknown as AiServicePrivate).appendTraitContext('base', ''); expect(result).toBe('base'); }); - it('inserts memory context before tag', () => { + it('inserts context before tag', () => { const base = 'some instructions\n\nchecklist\n'; - const memory = '\ntest memory\n'; - const result = (aiService as unknown as AiServicePrivate).appendMemoryContext(base, memory); - expect(result).toContain('test memory'); - expect(result.indexOf('memory_context')).toBeLessThan(result.indexOf('')); - }); - - it('appends memory context at end when no tag', () => { - const base = 'simple instructions without verification'; - const memory = '\ntest memory\n'; - const result = (aiService as unknown as AiServicePrivate).appendMemoryContext(base, memory); - expect(result).toBe(`${base}\n\n${memory}`); + const traitContext = '\ntest trait\n'; + const result = (aiService as unknown as AiServicePrivate).appendTraitContext(base, traitContext); + expect(result).toContain('test trait'); + expect(result.indexOf('traits_context')).toBeLessThan(result.indexOf('')); }); - it('selects relevant memories from model output ids', async () => { - (aiService.openAi.responses.create as Mock).mockResolvedValue({ - output: [{ type: 'message', content: [{ type: 'output_text', text: '[1,3]' }] }], - }); - const map = new Map([ - ['U1', [{ id: 1, slackId: 'U1', content: 'a' }]], - ['U2', [{ id: 3, slackId: 'U2', content: 'b' }]], - ]); - - const selected = await (aiService as unknown as AiServicePrivate).selectRelevantMemories('conv', map); - - expect(selected).toHaveLength(2); - }); - - it('returns empty memory selection when model response is malformed', async () => { - (aiService.openAi.responses.create as Mock).mockResolvedValue({ - output: [{ type: 'message', content: [{ type: 'output_text', text: '{not-json}' }] }], - }); - - const selected = await (aiService as unknown as AiServicePrivate).selectRelevantMemories( - 'conv', - new Map([['U1', [{ id: 1 }]]]), + it('fetches trait context end-to-end', async () => { + (aiService.traitPersistenceService.getAllTraitsForUsers as Mock).mockResolvedValue( + new Map([['U1', [{ id: 1, slackId: 'U1', content: 'prefers typescript' }]]]), ); - expect(selected).toEqual([]); - }); - - it('returns empty array without calling model when memoriesMap is empty', async () => { - const createSpy = aiService.openAi.responses.create as Mock; - - const selected = await (aiService as unknown as AiServicePrivate).selectRelevantMemories('conv', new Map()); - expect(selected).toEqual([]); - expect(createSpy).not.toHaveBeenCalled(); - }); - - it('passes conversation as input and memories as instructions (not duplicated)', async () => { - const createSpy = aiService.openAi.responses.create as Mock; - createSpy.mockResolvedValue({ - output: [{ type: 'message', content: [{ type: 'output_text', text: '[1]' }] }], - }); - const conversation = 'Alice: hey what is up'; - const map = new Map([['U1', [{ id: 1, slackId: 'U1', content: 'likes tea' }]]]); - - await (aiService as unknown as AiServicePrivate).selectRelevantMemories(conversation, map); - - expect(createSpy).toHaveBeenCalledTimes(1); - const callArgs = createSpy.mock.calls[0][0] as { instructions: string; input: string }; - expect(callArgs.input).toBe(conversation); - expect(callArgs.instructions).not.toBe(conversation); - expect(callArgs.instructions).not.toContain(conversation); - }); - - it('returns empty array and warns when model call throws', async () => { - (aiService.openAi.responses.create as Mock).mockRejectedValue(new Error('model error')); - const warnSpy = vi.spyOn(aiService.aiServiceLogger, 'warn'); - - const selected = await (aiService as unknown as AiServicePrivate).selectRelevantMemories( - 'conv', - new Map([['U1', [{ id: 1 }]]]), - ); + const context = await (aiService as unknown as AiServicePrivate).fetchTraitContext(['U1'], 'T1', [ + { slackId: 'U1', name: 'Alice', message: 'msg' }, + ]); - expect(selected).toEqual([]); - expect(warnSpy).toHaveBeenCalled(); + expect(context).toContain('prefers typescript'); }); - it('returns empty array when model returns non-array JSON', async () => { - (aiService.openAi.responses.create as Mock).mockResolvedValue({ - output: [{ type: 'message', content: [{ type: 'output_text', text: '{"ids":[1,2]}' }] }], - }); - - const selected = await (aiService as unknown as AiServicePrivate).selectRelevantMemories( - 'conv', - new Map([['U1', [{ id: 1 }]]]), + it('parses, de-duplicates, and caps extracted traits', () => { + const traits = (aiService as unknown as AiServicePrivate).parseTraitExtractionResult( + JSON.stringify([...Array.from({ length: 12 }, (_, i) => `trait-${i}`), 'trait-1']), ); - expect(selected).toEqual([]); + expect(traits.length).toBe(10); + expect(new Set(traits).size).toBe(10); }); - it('returns empty array when model returns empty array', async () => { - (aiService.openAi.responses.create as Mock).mockResolvedValue({ - output: [{ type: 'message', content: [{ type: 'output_text', text: '[]' }] }], - }); - - const selected = await (aiService as unknown as AiServicePrivate).selectRelevantMemories( - 'conv', - new Map([['U1', [{ id: 1 }]]]), - ); - - expect(selected).toEqual([]); + it('returns empty trait list for malformed extraction payload', () => { + const traits = (aiService as unknown as AiServicePrivate).parseTraitExtractionResult('{nope'); + expect(traits).toEqual([]); }); - it('filters out IDs from model response that do not exist in memoriesMap', async () => { + it('regenerates traits for users based on current memories', async () => { + (aiService.memoryPersistenceService.getAllMemoriesForUser as Mock) + .mockResolvedValueOnce([{ content: 'JR-15 loves TypeScript' }]) + .mockResolvedValueOnce([]); (aiService.openAi.responses.create as Mock).mockResolvedValue({ - output: [{ type: 'message', content: [{ type: 'output_text', text: '[1, 99]' }] }], + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: JSON.stringify(['JR-15 prefers TypeScript']) }], + }, + ], }); - const map = new Map([['U1', [{ id: 1, slackId: 'U1', content: 'likes tea' }]]]); - const selected = await (aiService as unknown as AiServicePrivate).selectRelevantMemories('conv', map); - - expect(selected).toHaveLength(1); - expect(selected[0]).toMatchObject({ id: 1 }); - }); + await (aiService as unknown as AiServicePrivate).regenerateTraitsForUsers('T1', ['U1', 'U2']); - it('fetches memory context end-to-end', async () => { - (aiService.memoryPersistenceService.getAllMemoriesForUsers as Mock).mockResolvedValue( - new Map([['U1', [{ id: 1, slackId: 'U1', content: 'likes tea' }]]]), - ); - vi.spyOn(aiService as unknown as AiServicePrivate, 'selectRelevantMemories').mockResolvedValue([ - { id: 1, slackId: 'U1', content: 'likes tea' }, + expect(aiService.traitPersistenceService.replaceTraitsForUser).toHaveBeenCalledWith('U1', 'T1', [ + 'JR-15 prefers TypeScript', ]); - - const context = await (aiService as unknown as AiServicePrivate).fetchMemoryContext( - ['U1'], - 'T1', - 'conversation', - [{ slackId: 'U1', name: 'Alice', message: 'msg' }], - ); - - expect(context).toContain('likes tea'); + expect(aiService.traitPersistenceService.replaceTraitsForUser).toHaveBeenCalledWith('U2', 'T1', []); }); it('extractMemories returns early when lock exists', async () => { @@ -900,6 +861,7 @@ describe('AIService', () => { await (aiService as unknown as AiServicePrivate).extractMemories('T1', 'C1', 'history', ['U1']); expect(aiService.memoryPersistenceService.saveMemories).not.toHaveBeenCalled(); + expect(aiService.traitPersistenceService.replaceTraitsForUser).not.toHaveBeenCalled(); }); it('extractMemories processes NEW, REINFORCE and EVOLVE modes', async () => { @@ -927,6 +889,7 @@ describe('AIService', () => { expect(aiService.memoryPersistenceService.saveMemories).toHaveBeenCalled(); expect(aiService.memoryPersistenceService.reinforceMemory).toHaveBeenCalledWith(10); expect(aiService.memoryPersistenceService.deleteMemory).toHaveBeenCalledWith(11); + expect(aiService.traitPersistenceService.replaceTraitsForUser).toHaveBeenCalled(); }); it('extractMemories skips malformed extraction items', async () => { diff --git a/packages/backend/src/ai/ai.service.ts b/packages/backend/src/ai/ai.service.ts index f26d0ab9..9f4181e4 100644 --- a/packages/backend/src/ai/ai.service.ts +++ b/packages/backend/src/ai/ai.service.ts @@ -19,12 +19,13 @@ import { REDPLOY_MOONBEAM_TEXT_PROMPT, GATE_MODEL, MOONBEAM_SLACK_ID, - MEMORY_SELECTION_PROMPT, MEMORY_EXTRACTION_PROMPT, + TRAIT_EXTRACTION_PROMPT, GPT_MODEL, } from './ai.constants'; import { MemoryPersistenceService } from './memory/memory.persistence.service'; -import type { MemoryWithSlackId } from '../shared/db/models/Memory'; +import { TraitPersistenceService } from './trait/trait.persistence.service'; +import type { TraitWithSlackId } from '../shared/db/models/Trait'; import { logError } from '../shared/logger/error-logging'; import { logger } from '../shared/logger/logger'; import { SlackService } from '../shared/services/slack/slack.service'; @@ -106,6 +107,7 @@ export class AIService { slackService = new SlackService(); slackPersistenceService = new SlackPersistenceService(); memoryPersistenceService = new MemoryPersistenceService(); + traitPersistenceService = new TraitPersistenceService(); aiServiceLogger = logger.child({ module: 'AIService' }); public decrementDaiyRequests(userId: string, teamId: string): Promise { @@ -399,17 +401,15 @@ export class AIService { const customPrompt = await this.slackPersistenceService.getCustomPrompt(user_id, team_id); const normalizedCustomPrompt = customPrompt?.trim() || null; - // Fetch and select relevant memories - const memoryContext = await this.fetchMemoryContext( + const traitContext = await this.fetchTraitContext( this.extractParticipantSlackIds(history, { includeSlackId: user_id }), team_id, - `${formattedHistory}\n\nUser prompt: ${prompt}`, history, ); const baseInstructions = normalizedCustomPrompt ? `${normalizedCustomPrompt}\n\n${getHistoryInstructions(formattedHistory)}` : getHistoryInstructions(formattedHistory); - const systemInstructions = this.appendMemoryContext(baseInstructions, memoryContext); + const systemInstructions = this.appendTraitContext(baseInstructions, traitContext); return this.openAi.responses .create({ @@ -487,13 +487,13 @@ export class AIService { const customPrompt = userId ? await this.slackPersistenceService.getCustomPrompt(userId, teamId) : null; const normalizedCustomPrompt = customPrompt?.trim() || null; - // Fetch and select relevant memories + // Fetch stable user traits instead of raw memories to reduce context size. const participantSlackIds = this.extractParticipantSlackIds(historyMessages, { excludeSlackIds: [MOONBEAM_SLACK_ID], }); - const memoryContext = await this.fetchMemoryContext(participantSlackIds, teamId, history, historyMessages); + const traitContext = await this.fetchTraitContext(participantSlackIds, teamId, historyMessages); const baseInstructions = normalizedCustomPrompt ?? MOONBEAM_SYSTEM_INSTRUCTIONS; - const systemInstructions = this.appendMemoryContext(baseInstructions, memoryContext); + const systemInstructions = this.appendTraitContext(baseInstructions, traitContext); const input = `${history}\n\n---\n[Tagged message to respond to]:\n${taggedMessage}`; @@ -533,77 +533,30 @@ export class AIService { }); } - private async selectRelevantMemories( - conversation: string, - memoriesMap: Map, - ): Promise { - if (memoriesMap.size === 0) return []; - - const formattedMemories = Array.from(memoriesMap.entries()) - .map(([slackId, memories]) => { - const lines = memories.map((m) => ` [ID:${m.id}] "${m.content}"`).join('\n'); - return `${slackId}:\n${lines}`; - }) - .join('\n\n'); - - const prompt = MEMORY_SELECTION_PROMPT.replace('{all_memories_grouped_by_user}', formattedMemories); - - try { - const raw = await this.openAi.responses - .create({ - model: GATE_MODEL, - instructions: prompt, - input: conversation, - }) - .then((x) => extractAndParseOpenAiResponse(x)); - - if (!raw) return []; - - let parsed: number[]; - - try { - parsed = JSON.parse(raw); - } catch { - this.aiServiceLogger.warn(`Memory selection returned malformed JSON: ${raw}`); - parsed = []; - } - - if (!Array.isArray(parsed)) return []; - const selectedIds = parsed.map(Number).filter((n) => !isNaN(n)); - - return Array.from(memoriesMap.values()) - .flat() - .filter((m) => selectedIds.includes(m.id)); - } catch (e) { - this.aiServiceLogger.warn('Memory selection failed, proceeding without memories:', e); - return []; - } - } - - private formatMemoryContext(memories: MemoryWithSlackId[], history: MessageWithName[]): string { - if (memories.length === 0) return ''; + private formatTraitContext(traits: TraitWithSlackId[], history: MessageWithName[]): string { + if (traits.length === 0) return ''; const nameMap = new Map(); history.forEach((msg) => { if (msg.slackId && msg.name) nameMap.set(msg.slackId, msg.name); }); - const grouped = new Map(); - for (const mem of memories) { - const slackId = mem.slackId || 'unknown'; + const grouped = new Map(); + for (const trait of traits) { + const slackId = trait.slackId || 'unknown'; if (!grouped.has(slackId)) grouped.set(slackId, []); - grouped.get(slackId)!.push(mem); + grouped.get(slackId)!.push(trait); } const lines = Array.from(grouped.entries()) - .map(([slackId, mems]) => { + .map(([slackId, userTraits]) => { const name = nameMap.get(slackId) || slackId; - const memLines = mems.map((m) => `"${m.content}"`).join(', '); - return `- ${name}: ${memLines}`; + const traitLines = userTraits.map((trait) => `"${trait.content}"`).join(', '); + return `- ${name}: ${traitLines}`; }) .join('\n'); - return `\nthings you remember about the people in this conversation:\n${lines}\n`; + return `\ncore beliefs and stable traits for people in this conversation:\n${lines}\n`; } private extractParticipantSlackIds( @@ -620,19 +573,18 @@ export class AIService { return ids; } - private async fetchMemoryContext( + private async fetchTraitContext( participantSlackIds: string[], teamId: string, - conversation: string, history: MessageWithName[], ): Promise { if (participantSlackIds.length === 0) return ''; - const memoriesMap = await this.memoryPersistenceService.getAllMemoriesForUsers(participantSlackIds, teamId); - const selectedMemories = await this.selectRelevantMemories(conversation, memoriesMap); - return this.formatMemoryContext(selectedMemories, history); + const traitsMap = await this.traitPersistenceService.getAllTraitsForUsers(participantSlackIds, teamId); + const traits = Array.from(traitsMap.values()).flat(); + return this.formatTraitContext(traits, history); } - private appendMemoryContext(baseInstructions: string, memoryContext: string): string { + private appendTraitContext(baseInstructions: string, memoryContext: string): string { if (!memoryContext) return baseInstructions; // Insert memory data before so the verification checklist remains the last thing the model sees const verificationTag = ''; @@ -767,6 +719,88 @@ export class AIService { }; } + private parseTraitExtractionResult(raw: string | undefined): string[] { + if (!raw) { + return []; + } + + try { + const parsed: unknown = JSON.parse(raw.trim()); + if (!Array.isArray(parsed)) { + return []; + } + + return Array.from( + new Set( + parsed + .filter((value): value is string => typeof value === 'string') + .map((value) => value.trim()) + .filter((value) => value.length > 0), + ), + ).slice(0, 10); + } catch { + this.aiServiceLogger.warn(`Trait extraction returned malformed JSON: ${raw}`); + return []; + } + } + + private async processWithConcurrencyLimit( + items: T[], + concurrency: number, + worker: (item: T) => Promise, + ): Promise { + const effectiveConcurrency = Math.max(1, Math.min(concurrency, items.length)); + let nextIndex = 0; + + const runners = Array.from({ length: effectiveConcurrency }, async () => { + const currentIndex = nextIndex; + nextIndex += 1; + + if (currentIndex >= items.length) { + return; + } + + await worker(items[currentIndex]); + }); + + await Promise.all(runners); + } + + private async regenerateTraitsForUsers(teamId: string, slackIds: string[]): Promise { + const uniqueSlackIds = Array.from(new Set(slackIds.filter((id) => /^U[A-Z0-9]+$/.test(id)))); + if (uniqueSlackIds.length === 0) { + return; + } + + const traitRegenerationConcurrency = 3; + + await this.processWithConcurrencyLimit(uniqueSlackIds, traitRegenerationConcurrency, async (slackId) => { + const memories = await this.memoryPersistenceService.getAllMemoriesForUser(slackId, teamId); + if (memories.length === 0) { + await this.traitPersistenceService.replaceTraitsForUser(slackId, teamId, []); + return; + } + + const memoryText = memories.map((memory, index) => `${index + 1}. ${memory.content}`).join('\n'); + const input = `User Slack ID: ${slackId}\n\nMemories:\n${memoryText}`; + + const rawTraits = await this.openAi.responses + .create({ + model: GATE_MODEL, + instructions: TRAIT_EXTRACTION_PROMPT, + input, + }) + .then((response) => extractAndParseOpenAiResponse(response)) + .catch((error) => { + this.aiServiceLogger.warn(`Trait synthesis failed for ${slackId} in ${teamId}:`, error); + return undefined; + }); + + const traits = this.parseTraitExtractionResult(rawTraits); + await this.traitPersistenceService.replaceTraitsForUser(slackId, teamId, traits); + }); + } + private async extractMemories( teamId: string, channelId: string, @@ -824,6 +858,8 @@ export class AIService { return; } + const touchedUsers = new Set(); + for (const extraction of extractions) { if (!extraction.slackId || !extraction.content || !extraction.mode) { this.aiServiceLogger.warn('Extraction missing required fields, skipping:', extraction); @@ -838,11 +874,13 @@ export class AIService { switch (extraction.mode) { case 'NEW': await this.memoryPersistenceService.saveMemories(extraction.slackId, teamId, [extraction.content]); + touchedUsers.add(extraction.slackId); break; case 'REINFORCE': if (extraction.existingMemoryId) { await this.memoryPersistenceService.reinforceMemory(extraction.existingMemoryId); + touchedUsers.add(extraction.slackId); } else { this.aiServiceLogger.warn('REINFORCE extraction missing existingMemoryId, skipping'); } @@ -853,6 +891,7 @@ export class AIService { await this.memoryPersistenceService.deleteMemory(extraction.existingMemoryId); } await this.memoryPersistenceService.saveMemories(extraction.slackId, teamId, [extraction.content]); + touchedUsers.add(extraction.slackId); break; default: @@ -860,6 +899,8 @@ export class AIService { } } + await this.regenerateTraitsForUsers(teamId, [...touchedUsers]); + this.aiServiceLogger.info(`Extraction complete for ${channelId}: ${extractions.length} observations processed`); } catch (e) { this.aiServiceLogger.warn('Memory extraction failed:', e); diff --git a/packages/backend/src/ai/trait/trait.persistence.service.spec.ts b/packages/backend/src/ai/trait/trait.persistence.service.spec.ts new file mode 100644 index 00000000..8caf1ea6 --- /dev/null +++ b/packages/backend/src/ai/trait/trait.persistence.service.spec.ts @@ -0,0 +1,136 @@ +import { vi } from 'vitest'; +import { getRepository } from 'typeorm'; +import { TraitPersistenceService } from './trait.persistence.service'; +import { SlackUser } from '../../shared/db/models/SlackUser'; +import { Trait } from '../../shared/db/models/Trait'; + +vi.mock('typeorm', async () => ({ + getRepository: vi.fn(), + Entity: () => vi.fn(), + Column: () => vi.fn(), + PrimaryGeneratedColumn: () => vi.fn(), + ManyToOne: () => vi.fn(), + OneToMany: () => vi.fn(), + OneToOne: () => vi.fn(), + Unique: () => vi.fn(), + JoinColumn: () => vi.fn(), +})); + +vi.mock('../../shared/logger/logger', async () => ({ + logger: { + child: vi.fn().mockReturnValue({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + }, +})); + +describe('TraitPersistenceService', () => { + let service: TraitPersistenceService; + let mockSlackUserRepo: Record; + let mockTraitRepo: Record; + + const mockUser: Partial = { + id: 1, + slackId: 'U123', + teamId: 'T456', + name: 'testuser', + isBot: false, + botId: '', + }; + + beforeEach(() => { + vi.clearAllMocks(); + service = new TraitPersistenceService(); + + mockSlackUserRepo = { + findOne: vi.fn(), + }; + + mockTraitRepo = { + query: vi.fn(), + save: vi.fn(), + }; + + (getRepository as Mock).mockImplementation((entity) => { + if (entity === SlackUser) return mockSlackUserRepo; + if (entity === Trait) return mockTraitRepo; + return {}; + }); + }); + + describe('replaceTraitsForUser', () => { + it('replaces traits and caps output to 10', async () => { + mockSlackUserRepo.findOne.mockResolvedValue(mockUser); + mockTraitRepo.query.mockResolvedValue(undefined); + mockTraitRepo.save.mockResolvedValue([]); + + await service.replaceTraitsForUser( + 'U123', + 'T456', + Array.from({ length: 12 }, (_, i) => `trait-${i + 1}`), + ); + + expect(mockTraitRepo.query).toHaveBeenCalledWith('DELETE FROM trait WHERE userIdId = ? AND teamId = ?', [ + 1, + 'T456', + ]); + expect(mockTraitRepo.save).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ content: 'trait-1' })]), + ); + expect((mockTraitRepo.save as Mock).mock.calls[0][0]).toHaveLength(10); + }); + + it('returns empty array when user is missing', async () => { + mockSlackUserRepo.findOne.mockResolvedValue(null); + + const result = await service.replaceTraitsForUser('UNKNOWN', 'T456', ['a']); + + expect(result).toEqual([]); + expect(mockTraitRepo.query).not.toHaveBeenCalled(); + }); + + it('deletes traits and skips save for empty normalized traits', async () => { + mockSlackUserRepo.findOne.mockResolvedValue(mockUser); + mockTraitRepo.query.mockResolvedValue(undefined); + + const result = await service.replaceTraitsForUser('U123', 'T456', [' ', '']); + + expect(result).toEqual([]); + expect(mockTraitRepo.save).not.toHaveBeenCalled(); + }); + }); + + describe('getAllTraitsForUser', () => { + it('returns traits for user', async () => { + mockTraitRepo.query.mockResolvedValue([{ id: 1, content: 'likes TypeScript', slackId: 'U123' }]); + + const result = await service.getAllTraitsForUser('U123', 'T456'); + + expect(result).toEqual([{ id: 1, content: 'likes TypeScript', slackId: 'U123' }]); + }); + + it('returns empty array on query failure', async () => { + mockTraitRepo.query.mockRejectedValue(new Error('db fail')); + + const result = await service.getAllTraitsForUser('U123', 'T456'); + + expect(result).toEqual([]); + }); + }); + + describe('getAllTraitsForUsers', () => { + it('returns grouped traits by slack id', async () => { + mockTraitRepo.query.mockResolvedValueOnce([ + { id: 1, content: 'likes TypeScript', slackId: 'U123' }, + { id: 2, content: 'hates Java', slackId: 'U789' }, + ]); + + const result = await service.getAllTraitsForUsers(['U123', 'U789'], 'T456'); + + expect(result.get('U123')?.length).toBe(1); + expect(result.get('U789')?.length).toBe(1); + }); + }); +}); diff --git a/packages/backend/src/ai/trait/trait.persistence.service.ts b/packages/backend/src/ai/trait/trait.persistence.service.ts new file mode 100644 index 00000000..3da79b45 --- /dev/null +++ b/packages/backend/src/ai/trait/trait.persistence.service.ts @@ -0,0 +1,104 @@ +import { getRepository } from 'typeorm'; +import type { TraitWithSlackId } from '../../shared/db/models/Trait'; +import { Trait } from '../../shared/db/models/Trait'; +import { SlackUser } from '../../shared/db/models/SlackUser'; +import { logError } from '../../shared/logger/error-logging'; +import { logger } from '../../shared/logger/logger'; + +const MAX_TRAITS_PER_USER = 10; + +export class TraitPersistenceService { + private traitLogger = logger.child({ module: 'TraitPersistenceService' }); + + async replaceTraitsForUser(slackId: string, teamId: string, contents: string[]): Promise { + const user = await getRepository(SlackUser).findOne({ where: { slackId, teamId } }); + if (!user) { + this.traitLogger.warn(`Cannot save traits: user ${slackId} not found in team ${teamId}`); + return []; + } + + const normalizedContents = Array.from( + new Set(contents.map((content) => content.trim()).filter((content) => content.length > 0)), + ).slice(0, MAX_TRAITS_PER_USER); + + return getRepository(Trait) + .query('DELETE FROM trait WHERE userIdId = ? AND teamId = ?', [user.id, teamId]) + .then(async () => { + if (normalizedContents.length === 0) { + return []; + } + + const traits = normalizedContents.map((content) => { + const trait = new Trait(); + trait.userId = user; + trait.teamId = teamId; + trait.content = content; + return trait; + }); + + return getRepository(Trait).save(traits); + }) + .catch((e) => { + logError(this.traitLogger, 'Error replacing traits for user', e, { + slackId, + teamId, + traitCount: normalizedContents.length, + }); + return []; + }); + } + + async getAllTraitsForUser(slackId: string, teamId: string): Promise { + return getRepository(Trait) + .query( + `SELECT t.*, u.slackId FROM trait t + INNER JOIN slack_user u ON t.userIdId = u.id + WHERE u.slackId = ? AND u.teamId = ? + ORDER BY t.updatedAt DESC`, + [slackId, teamId], + ) + .catch((e) => { + logError(this.traitLogger, 'Error fetching all traits for user', e, { + slackId, + teamId, + }); + return []; + }); + } + + async getAllTraitsForUsers(slackIds: string[], teamId: string): Promise> { + const result = new Map(); + const uniqueSlackIds = Array.from(new Set(slackIds)); + + if (uniqueSlackIds.length === 0) { + return result; + } + + const placeholders = uniqueSlackIds.map(() => '?').join(', '); + + return getRepository(Trait) + .query( + `SELECT t.*, u.slackId FROM trait t + INNER JOIN slack_user u ON t.userIdId = u.id + WHERE u.teamId = ? AND u.slackId IN (${placeholders}) + ORDER BY t.updatedAt DESC`, + [teamId, ...uniqueSlackIds], + ) + .then((traits: TraitWithSlackId[]) => { + traits.forEach((trait) => { + const existingTraits = result.get(trait.slackId) ?? []; + existingTraits.push(trait); + result.set(trait.slackId, existingTraits); + }); + + return result; + }) + .catch((e) => { + logError(this.traitLogger, 'Error fetching all traits for users', e, { + teamId, + slackIdCount: uniqueSlackIds.length, + }); + return result; + }); + } +} diff --git a/packages/backend/src/shared/db/models/SlackUser.ts b/packages/backend/src/shared/db/models/SlackUser.ts index 6d6628c0..50ff3d58 100644 --- a/packages/backend/src/shared/db/models/SlackUser.ts +++ b/packages/backend/src/shared/db/models/SlackUser.ts @@ -3,6 +3,7 @@ import { Activity } from './Activity'; import { Memory } from './Memory'; import { Message } from './Message'; import { Portfolio } from './Portfolio'; +import { Trait } from './Trait'; @Entity() @Unique(['slackId', 'teamId']) @@ -31,6 +32,9 @@ export class SlackUser { @OneToMany(() => Memory, (memory) => memory.userId) public memories?: Memory[]; + @OneToMany(() => Trait, (trait) => trait.userId) + public traits?: Trait[]; + @OneToMany(() => Message, (message) => message.userId) public messages?: Message[]; diff --git a/packages/backend/src/shared/db/models/Trait.ts b/packages/backend/src/shared/db/models/Trait.ts new file mode 100644 index 00000000..2636c269 --- /dev/null +++ b/packages/backend/src/shared/db/models/Trait.ts @@ -0,0 +1,28 @@ +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { SlackUser } from './SlackUser'; + +@Entity() +export class Trait { + @PrimaryGeneratedColumn() + public id!: number; + + @ManyToOne(() => SlackUser, (user) => user.traits) + public userId!: SlackUser; + + @Column({ default: 'NOT_AVAILABLE' }) + public teamId!: string; + + @Column('text') + public content!: string; + + @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) + public createdAt!: Date; + + @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP' }) + public updatedAt!: Date; +} + +/** Raw SQL result shape when JOINing trait with slack_user (includes slackId from the JOIN). */ +export interface TraitWithSlackId extends Trait { + slackId: string; +}