From f928f5489cb6f5c5a0f107df3df3864c99116e11 Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Mon, 20 Apr 2026 10:39:27 -0400 Subject: [PATCH 01/18] Adjusted the TRAIT_EXTRACTION_PROMPT to store traits correctly --- packages/backend/src/ai/ai.constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/ai/ai.constants.ts b/packages/backend/src/ai/ai.constants.ts index 65ede081..df93493a 100644 --- a/packages/backend/src/ai/ai.constants.ts +++ b/packages/backend/src/ai/ai.constants.ts @@ -157,7 +157,7 @@ Requirements: 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"] +- Example: ["Prefers TypeScript as his primary programming language", "Strongly dislikes Donald Trump"] - If no strong traits are present, return []`; export const DAILY_MEMORY_JOB_CONCURRENCY = 50; From 58adfc04d227c1d3e724f9bf378b6b1746730f1d Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Mon, 20 Apr 2026 10:48:48 -0400 Subject: [PATCH 02/18] moved traits out --- packages/backend/src/ai/ai.controller.spec.ts | 53 ---------------- packages/backend/src/ai/ai.controller.ts | 40 ------------ packages/backend/src/index.ts | 2 + .../src/trait/trait.controller.spec.ts | 35 +++++++++++ .../backend/src/trait/trait.controller.ts | 19 ++++++ .../backend/src/trait/trait.service.spec.ts | 62 +++++++++++++++++++ packages/backend/src/trait/trait.service.ts | 41 ++++++++++++ 7 files changed, 159 insertions(+), 93 deletions(-) create mode 100644 packages/backend/src/trait/trait.controller.spec.ts create mode 100644 packages/backend/src/trait/trait.controller.ts create mode 100644 packages/backend/src/trait/trait.service.spec.ts create mode 100644 packages/backend/src/trait/trait.service.ts diff --git a/packages/backend/src/ai/ai.controller.spec.ts b/packages/backend/src/ai/ai.controller.spec.ts index f0440310..4a55f071 100644 --- a/packages/backend/src/ai/ai.controller.spec.ts +++ b/packages/backend/src/ai/ai.controller.spec.ts @@ -12,10 +12,6 @@ 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, @@ -32,12 +28,6 @@ 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(), })); @@ -61,7 +51,6 @@ describe('aiController', () => { vi.clearAllMocks(); setCustomPrompt.mockResolvedValue(true); clearCustomPrompt.mockResolvedValue(true); - getAllTraitsForUser.mockResolvedValue([]); }); it('handles /text', async () => { @@ -98,48 +87,6 @@ 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 e40e6aa0..f13469bf 100644 --- a/packages/backend/src/ai/ai.controller.ts +++ b/packages/backend/src/ai/ai.controller.ts @@ -8,55 +8,15 @@ 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/index.ts b/packages/backend/src/index.ts index accd7a02..b9108f77 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -40,6 +40,7 @@ import { searchController } from './search/search.controller'; import { authController } from './auth/auth.controller'; import { authMiddleware } from './shared/middleware/authMiddleware'; import { dashboardController } from './dashboard/dashboard.controller'; +import { traitController } from './trait/trait.controller'; const app: Application = express(); const PORT = process.env.PORT || 3000; @@ -109,6 +110,7 @@ app.use('/quote', quoteController); app.use('/rep', reactionController); app.use('/store', storeController); app.use('/summary', summaryController); +app.use('traits', traitController); app.use('/walkie', walkieController); const slackService = new SlackService(); diff --git a/packages/backend/src/trait/trait.controller.spec.ts b/packages/backend/src/trait/trait.controller.spec.ts new file mode 100644 index 00000000..a0c2617c --- /dev/null +++ b/packages/backend/src/trait/trait.controller.spec.ts @@ -0,0 +1,35 @@ +import { vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; + +const { sendTraitsForUser } = vi.hoisted(() => ({ + sendTraitsForUser: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('./trait.service', async () => ({ + TraitService: classMock(() => ({ + sendTraitsForUser, + })), +})); + +vi.mock('../shared/middleware/suppression', async () => ({ + suppressedMiddleware: (_req: unknown, _res: unknown, next: () => void) => next(), +})); + +import { traitController } from './trait.controller'; + +describe('traitController', () => { + const app = express(); + app.use(express.json()); + app.use('/', traitController); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('handles /traits and responds immediately', async () => { + await request(app).post('/').send({ user_id: 'U1', team_id: 'T1', channel_id: 'C1' }).expect(200, ''); + + expect(sendTraitsForUser).toHaveBeenCalledWith('U1', 'T1', 'C1'); + }); +}); diff --git a/packages/backend/src/trait/trait.controller.ts b/packages/backend/src/trait/trait.controller.ts new file mode 100644 index 00000000..b455e0bb --- /dev/null +++ b/packages/backend/src/trait/trait.controller.ts @@ -0,0 +1,19 @@ +import type { Router } from 'express'; +import express from 'express'; +import { suppressedMiddleware } from '../shared/middleware/suppression'; +import { TraitService } from './trait.service'; + +export const traitController: Router = express.Router(); + +const traitService = new TraitService(); + +traitController.use(suppressedMiddleware); + +traitController.post('/', (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 traitService.sendTraitsForUser(user_id, team_id, channel_id); +}); diff --git a/packages/backend/src/trait/trait.service.spec.ts b/packages/backend/src/trait/trait.service.spec.ts new file mode 100644 index 00000000..36a91fcb --- /dev/null +++ b/packages/backend/src/trait/trait.service.spec.ts @@ -0,0 +1,62 @@ +import { vi } from 'vitest'; +import { TraitService } from './trait.service'; + +const { getAllTraitsForUser, sendEphemeral } = vi.hoisted(() => ({ + getAllTraitsForUser: vi.fn().mockResolvedValue([]), + sendEphemeral: vi.fn().mockResolvedValue({ ok: true }), +})); + +vi.mock('../ai/trait/trait.persistence.service', async () => ({ + TraitPersistenceService: classMock(() => ({ + getAllTraitsForUser, + })), +})); + +vi.mock('../shared/services/web/web.service', async () => ({ + WebService: classMock(() => ({ + sendEphemeral, + })), +})); + +describe('TraitService', () => { + let service: TraitService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new TraitService(); + }); + + it('sends formatted traits when they exist', async () => { + getAllTraitsForUser.mockResolvedValue([ + { + content: 'JR-15 prefers TypeScript as his programming language', + updatedAt: new Date('2026-04-01T00:00:00.000Z'), + }, + ]); + + await service.sendTraitsForUser('U1', 'T1', 'C1'); + + 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 service.sendTraitsForUser('U1', 'T1', 'C1'); + + 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 service.sendTraitsForUser('U1', 'T1', 'C1'); + + expect(sendEphemeral).toHaveBeenCalledWith('C1', 'Sorry, something went wrong fetching your traits.', 'U1'); + }); +}); diff --git a/packages/backend/src/trait/trait.service.ts b/packages/backend/src/trait/trait.service.ts new file mode 100644 index 00000000..9a88329f --- /dev/null +++ b/packages/backend/src/trait/trait.service.ts @@ -0,0 +1,41 @@ +import { TraitPersistenceService } from '../ai/trait/trait.persistence.service'; +import { WebService } from '../shared/services/web/web.service'; +import { logError } from '../shared/logger/error-logging'; +import { logger } from '../shared/logger/logger'; + +export class TraitService { + private readonly traitPersistenceService = new TraitPersistenceService(); + private readonly webService = new WebService(); + private readonly traitLogger = logger.child({ module: 'TraitService' }); + + public async sendTraitsForUser(userId: string, teamId: string, channelId: string): Promise { + try { + const traits = await this.traitPersistenceService.getAllTraitsForUser(userId, teamId); + + if (traits.length === 0) { + await this.webService.sendEphemeral(channelId, "Moonbeam doesn't have any core traits about you yet.", userId); + 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}`; + await this.webService.sendEphemeral(channelId, message, userId); + } catch (e) { + logError(this.traitLogger, 'Failed to fetch traits for /ai/traits command', e, { + userId, + teamId, + channelId, + }); + await this.webService.sendEphemeral(channelId, 'Sorry, something went wrong fetching your traits.', userId); + } + } +} From 487bc8066f6a942f0c5b17739cf40e1ad728a1d6 Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Mon, 20 Apr 2026 11:16:06 -0400 Subject: [PATCH 03/18] Better separation of concerns --- packages/backend/src/ai/ai.service.spec.ts | 198 +----------- packages/backend/src/ai/ai.service.ts | 285 ++---------------- .../src/ai/memory/memory.service.spec.ts | 96 ++++++ .../backend/src/ai/memory/memory.service.ts | 134 ++++++++ .../src/ai/trait/trait.service.spec.ts | 113 +++++++ .../backend/src/ai/trait/trait.service.ts | 164 ++++++++++ packages/backend/src/index.ts | 2 +- 7 files changed, 548 insertions(+), 444 deletions(-) create mode 100644 packages/backend/src/ai/memory/memory.service.spec.ts create mode 100644 packages/backend/src/ai/memory/memory.service.ts create mode 100644 packages/backend/src/ai/trait/trait.service.spec.ts create mode 100644 packages/backend/src/ai/trait/trait.service.ts diff --git a/packages/backend/src/ai/ai.service.spec.ts b/packages/backend/src/ai/ai.service.spec.ts index e6ea99a6..cddf0e99 100644 --- a/packages/backend/src/ai/ai.service.spec.ts +++ b/packages/backend/src/ai/ai.service.spec.ts @@ -5,6 +5,8 @@ import path from 'path'; import { AIService } from './ai.service'; import type { MessageWithName } from '../shared/models/message/message-with-name'; import { MOONBEAM_SLACK_ID } from './ai.constants'; +import { TraitService } from './trait/trait.service'; +import { MemoryService } from './memory/memory.service'; const buildAiService = (): AIService => { const ai = new AIService(); @@ -79,6 +81,14 @@ const buildAiService = (): AIService => { debug: vi.fn(), } as unknown as AIService['aiServiceLogger']; + ai.traitService = new TraitService( + ai.traitPersistenceService as never, + ai.memoryPersistenceService as never, + ai.aiServiceLogger as never, + ); + + ai.memoryService = new MemoryService(); + return ai; }; @@ -731,194 +741,6 @@ describe('AIService', () => { }); }); - describe('memory and trait helpers', () => { - type AiServicePrivate = typeof aiService & { - extractParticipantSlackIds: ( - messages: Array<{ slackId: string; name: string; message: string }>, - options: { includeSlackId?: string; excludeSlackIds?: string[] }, - ) => string[]; - formatTraitContext: ( - traits: Array<{ slackId: string; content: string }>, - messages: Array<{ slackId: string; name: string; message: string }>, - ) => string; - appendTraitContext: (base: string, context: string) => string; - fetchTraitContext: ( - participantIds: string[], - teamId: 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; - }; - - it('extracts participant slack ids with include/exclude rules', () => { - const ids = (aiService as unknown as AiServicePrivate).extractParticipantSlackIds( - [ - { slackId: 'U1', name: 'A', message: 'm1' }, - { slackId: 'U2', name: 'B', message: 'm2' }, - { slackId: 'U2', name: 'B', message: 'm3' }, - ], - { includeSlackId: 'U3', excludeSlackIds: ['U1'] }, - ); - - expect(ids).toEqual(['U2', 'U3']); - }); - - it('formats trait context grouped by participant', () => { - const text = (aiService as unknown as AiServicePrivate).formatTraitContext( - [ - { slackId: 'U1', content: 'prefers typescript' }, - { slackId: 'U2', content: 'dislikes donald trump' }, - ], - [ - { slackId: 'U1', name: 'Alice', message: 'hi' }, - { slackId: 'U2', name: 'Bob', message: 'hello' }, - ], - ); - - expect(text).toContain('traits_context'); - expect(text).toContain('Alice'); - expect(text).toContain('prefers typescript'); - expect(text).toContain('Bob'); - }); - - it('returns base instructions when no context', () => { - const result = (aiService as unknown as AiServicePrivate).appendTraitContext('base', ''); - expect(result).toBe('base'); - }); - - it('inserts context before tag', () => { - const base = 'some instructions\n\nchecklist\n'; - 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('fetches trait context end-to-end', async () => { - (aiService.traitPersistenceService.getAllTraitsForUsers as Mock).mockResolvedValue( - new Map([['U1', [{ id: 1, slackId: 'U1', content: 'prefers typescript' }]]]), - ); - - const context = await (aiService as unknown as AiServicePrivate).fetchTraitContext(['U1'], 'T1', [ - { slackId: 'U1', name: 'Alice', message: 'msg' }, - ]); - - expect(context).toContain('prefers typescript'); - }); - - 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(traits.length).toBe(10); - expect(new Set(traits).size).toBe(10); - }); - - it('returns empty trait list for malformed extraction payload', () => { - const traits = (aiService as unknown as AiServicePrivate).parseTraitExtractionResult('{nope'); - expect(traits).toEqual([]); - }); - - 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: JSON.stringify(['JR-15 prefers TypeScript']) }], - }, - ], - }); - - await (aiService as unknown as AiServicePrivate).regenerateTraitsForUsers('T1', ['U1', 'U2']); - - expect(aiService.traitPersistenceService.replaceTraitsForUser).toHaveBeenCalledWith('U1', 'T1', [ - 'JR-15 prefers TypeScript', - ]); - expect(aiService.traitPersistenceService.replaceTraitsForUser).toHaveBeenCalledWith('U2', 'T1', []); - }); - - it('extractMemories returns early when lock exists', async () => { - (aiService.redis.getExtractionLock as Mock).mockResolvedValue('1'); - const infoSpy = vi.spyOn(aiService.aiServiceLogger, 'info'); - - await (aiService as unknown as AiServicePrivate).extractMemories('T1', 'C1', 'history', ['U1']); - - expect(infoSpy).toHaveBeenCalled(); - }); - - it('extractMemories handles NONE response', async () => { - (aiService.redis.getExtractionLock as Mock).mockResolvedValue(null); - (aiService.openAi.responses.create as Mock).mockResolvedValue({ - output: [{ type: 'message', content: [{ type: 'output_text', text: 'NONE' }] }], - }); - - 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 () => { - (aiService.redis.getExtractionLock as Mock).mockResolvedValue(null); - (aiService.openAi.responses.create as Mock).mockResolvedValue({ - output: [ - { - type: 'message', - content: [ - { - type: 'output_text', - text: JSON.stringify([ - { slackId: 'U123ABC', content: 'new memory', mode: 'NEW' }, - { slackId: 'U123ABC', content: 'reinforce memory', mode: 'REINFORCE', existingMemoryId: 10 }, - { slackId: 'U123ABC', content: 'evolved memory', mode: 'EVOLVE', existingMemoryId: 11 }, - ]), - }, - ], - }, - ], - }); - - await (aiService as unknown as AiServicePrivate).extractMemories('T1', 'C1', 'history', ['U123ABC']); - - 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 () => { - (aiService.redis.getExtractionLock as Mock).mockResolvedValue(null); - const warnSpy = vi.spyOn(aiService.aiServiceLogger, 'warn'); - (aiService.openAi.responses.create as Mock).mockResolvedValue({ - output: [ - { - type: 'message', - content: [ - { - type: 'output_text', - text: JSON.stringify([ - { mode: 'NEW' }, - { slackId: 'invalid', content: 'x', mode: 'NEW' }, - { slackId: 'U123ABC', content: 'x', mode: 'UNKNOWN' }, - ]), - }, - ], - }, - ], - }); - - await (aiService as unknown as AiServicePrivate).extractMemories('T1', 'C1', 'history', ['U123ABC']); - - expect(warnSpy).toHaveBeenCalled(); - }); - }); - describe('extractMemoriesForChannel', () => { it('returns early when there are no messages in the last 24 hours', async () => { (aiService.historyService.getLast24HoursForChannel as Mock).mockResolvedValue([]); diff --git a/packages/backend/src/ai/ai.service.ts b/packages/backend/src/ai/ai.service.ts index 9f4181e4..5836a572 100644 --- a/packages/backend/src/ai/ai.service.ts +++ b/packages/backend/src/ai/ai.service.ts @@ -19,13 +19,11 @@ import { REDPLOY_MOONBEAM_TEXT_PROMPT, GATE_MODEL, MOONBEAM_SLACK_ID, - MEMORY_EXTRACTION_PROMPT, TRAIT_EXTRACTION_PROMPT, GPT_MODEL, } from './ai.constants'; -import { MemoryPersistenceService } from './memory/memory.persistence.service'; -import { TraitPersistenceService } from './trait/trait.persistence.service'; -import type { TraitWithSlackId } from '../shared/db/models/Trait'; +import { TraitService } from './trait/trait.service'; +import { MemoryService } from './memory/memory.service'; import { logError } from '../shared/logger/error-logging'; import { logger } from '../shared/logger/logger'; import { SlackService } from '../shared/services/slack/slack.service'; @@ -42,13 +40,6 @@ import type { Part } from '@google/genai'; import { GoogleGenAI } from '@google/genai'; import sharp from 'sharp'; -interface ExtractionResult { - slackId: string; - content: string; - mode: 'NEW' | 'REINFORCE' | 'EVOLVE'; - existingMemoryId: number | null; -} - interface ReleaseCommit { sha: string; subject: string; @@ -106,8 +97,8 @@ export class AIService { webService = new WebService(); slackService = new SlackService(); slackPersistenceService = new SlackPersistenceService(); - memoryPersistenceService = new MemoryPersistenceService(); - traitPersistenceService = new TraitPersistenceService(); + memoryService = new MemoryService(); + traitService = new TraitService(); aiServiceLogger = logger.child({ module: 'AIService' }); public decrementDaiyRequests(userId: string, teamId: string): Promise { @@ -401,15 +392,15 @@ export class AIService { const customPrompt = await this.slackPersistenceService.getCustomPrompt(user_id, team_id); const normalizedCustomPrompt = customPrompt?.trim() || null; - const traitContext = await this.fetchTraitContext( - this.extractParticipantSlackIds(history, { includeSlackId: user_id }), + const traitContext = await this.traitService.fetchTraitContext( + this.traitService.extractParticipantSlackIds(history, { includeSlackId: user_id }), team_id, history, ); const baseInstructions = normalizedCustomPrompt ? `${normalizedCustomPrompt}\n\n${getHistoryInstructions(formattedHistory)}` : getHistoryInstructions(formattedHistory); - const systemInstructions = this.appendTraitContext(baseInstructions, traitContext); + const systemInstructions = this.traitService.appendTraitContext(baseInstructions, traitContext); return this.openAi.responses .create({ @@ -487,13 +478,12 @@ export class AIService { const customPrompt = userId ? await this.slackPersistenceService.getCustomPrompt(userId, teamId) : null; const normalizedCustomPrompt = customPrompt?.trim() || null; - // Fetch stable user traits instead of raw memories to reduce context size. - const participantSlackIds = this.extractParticipantSlackIds(historyMessages, { + const participantSlackIds = this.traitService.extractParticipantSlackIds(historyMessages, { excludeSlackIds: [MOONBEAM_SLACK_ID], }); - const traitContext = await this.fetchTraitContext(participantSlackIds, teamId, historyMessages); + const traitContext = await this.traitService.fetchTraitContext(participantSlackIds, teamId, historyMessages); const baseInstructions = normalizedCustomPrompt ?? MOONBEAM_SYSTEM_INSTRUCTIONS; - const systemInstructions = this.appendTraitContext(baseInstructions, traitContext); + const systemInstructions = this.traitService.appendTraitContext(baseInstructions, traitContext); const input = `${history}\n\n---\n[Tagged message to respond to]:\n${taggedMessage}`; @@ -533,75 +523,12 @@ export class AIService { }); } - 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 trait of traits) { - const slackId = trait.slackId || 'unknown'; - if (!grouped.has(slackId)) grouped.set(slackId, []); - grouped.get(slackId)!.push(trait); - } - - const lines = Array.from(grouped.entries()) - .map(([slackId, userTraits]) => { - const name = nameMap.get(slackId) || slackId; - const traitLines = userTraits.map((trait) => `"${trait.content}"`).join(', '); - return `- ${name}: ${traitLines}`; - }) - .join('\n'); - - return `\ncore beliefs and stable traits for people in this conversation:\n${lines}\n`; - } - - private extractParticipantSlackIds( - history: MessageWithName[], - options?: { includeSlackId?: string; excludeSlackIds?: string[] }, - ): string[] { - const excludeSet = new Set(options?.excludeSlackIds || []); - const ids = [ - ...new Set(history.filter((msg) => msg.slackId && !excludeSet.has(msg.slackId!)).map((msg) => msg.slackId!)), - ]; - if (options?.includeSlackId && !ids.includes(options.includeSlackId)) { - ids.push(options.includeSlackId); - } - return ids; - } - - private async fetchTraitContext( - participantSlackIds: string[], - teamId: string, - history: MessageWithName[], - ): Promise { - if (participantSlackIds.length === 0) return ''; - const traitsMap = await this.traitPersistenceService.getAllTraitsForUsers(participantSlackIds, teamId); - const traits = Array.from(traitsMap.values()).flat(); - return this.formatTraitContext(traits, history); - } - - 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 = ''; - const insertionPoint = baseInstructions.lastIndexOf(verificationTag); - if (insertionPoint !== -1) { - return `${baseInstructions.slice(0, insertionPoint)}${memoryContext}\n\n${baseInstructions.slice(insertionPoint)}`; - } - // Fallback for custom prompts that don't use the standard tag - return `${baseInstructions}\n\n${memoryContext}`; - } - public async extractMemoriesForChannel(teamId: string, channelId: string): Promise { const historyMessages = await this.historyService.getLast24HoursForChannel(teamId, channelId); if (historyMessages.length === 0) return; const history = this.formatHistory(historyMessages); - const participantSlackIds = this.extractParticipantSlackIds(historyMessages, { + const participantSlackIds = this.traitService.extractParticipantSlackIds(historyMessages, { excludeSlackIds: [MOONBEAM_SLACK_ID], }); if (participantSlackIds.length === 0) return; @@ -719,85 +646,15 @@ 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 + return this.traitService.regenerateTraitsForUsers(teamId, slackIds, async (input) => { + return 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); + .then((response) => extractAndParseOpenAiResponse(response)); }); } @@ -807,104 +664,22 @@ export class AIService { conversationHistory: string, participantSlackIds: string[], ): Promise { - const locked = await this.redis.getExtractionLock(channelId, teamId); - if (locked) { - this.aiServiceLogger.info(`Extraction lock active for ${channelId}-${teamId}, skipping`); - return; - } - await this.redis.setExtractionLock(channelId, teamId); - - try { - const existingMemoriesMap = await this.memoryPersistenceService.getAllMemoriesForUsers( - participantSlackIds, - teamId, - ); - - const existingMemoriesText = - existingMemoriesMap.size > 0 - ? Array.from(existingMemoriesMap.entries()) - .map(([slackId, memories]) => { - const lines = memories.map((m) => ` [ID:${m.id}] "${m.content}"`).join('\n'); - return `${slackId}:\n${lines}`; - }) - .join('\n\n') - : '(no existing memories)'; - - const extractionInput = conversationHistory; - const prompt = MEMORY_EXTRACTION_PROMPT.replace('{existing_memories}', existingMemoriesText); - - const result = await this.openAi.responses - .create({ - model: GATE_MODEL, - instructions: prompt, - input: extractionInput, - }) - .then((x) => extractAndParseOpenAiResponse(x)); - - if (!result) { - this.aiServiceLogger.warn('Extraction returned no result'); - return; - } - - const trimmed = result.trim(); - if (trimmed === 'NONE' || trimmed === '"NONE"') return; - - let extractions: Array>; - try { - const parsed: unknown = JSON.parse(trimmed); - extractions = Array.isArray(parsed) ? parsed : [parsed]; - } catch { - this.aiServiceLogger.warn(`Extraction returned malformed JSON: ${trimmed}`); - 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); - continue; - } - - if (!/^U[A-Z0-9]+$/.test(extraction.slackId)) { - this.aiServiceLogger.warn(`Invalid slackId format: ${extraction.slackId}`); - continue; - } - - 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'); - } - break; - - case 'EVOLVE': - if (extraction.existingMemoryId) { - await this.memoryPersistenceService.deleteMemory(extraction.existingMemoryId); - } - await this.memoryPersistenceService.saveMemories(extraction.slackId, teamId, [extraction.content]); - touchedUsers.add(extraction.slackId); - break; - - default: - this.aiServiceLogger.warn(`Unknown extraction mode: ${String(extraction.mode)}`); - } - } - - 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); - } + return this.memoryService.extractMemories( + teamId, + channelId, + conversationHistory, + participantSlackIds, + async (prompt, input) => { + return this.openAi.responses + .create({ + model: GATE_MODEL, + instructions: prompt, + input, + }) + .then((x) => extractAndParseOpenAiResponse(x)); + }, + async (regenTeamId, slackIds) => this.regenerateTraitsForUsers(regenTeamId, slackIds), + ); } sendImage(image: string | undefined, userId: string, teamId: string, channel: string, text: string): void { diff --git a/packages/backend/src/ai/memory/memory.service.spec.ts b/packages/backend/src/ai/memory/memory.service.spec.ts new file mode 100644 index 00000000..bc5e07c9 --- /dev/null +++ b/packages/backend/src/ai/memory/memory.service.spec.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { MemoryService } from './memory.service'; + +describe('AIMemoryService', () => { + let memoryPersistenceService: { + getAllMemoriesForUsers: ReturnType; + saveMemories: ReturnType; + reinforceMemory: ReturnType; + deleteMemory: ReturnType; + }; + let extractionLockStore: { + getExtractionLock: ReturnType; + setExtractionLock: ReturnType; + }; + let logger: { + info: ReturnType; + warn: ReturnType; + }; + let service: MemoryService; + + beforeEach(() => { + memoryPersistenceService = { + getAllMemoriesForUsers: vi.fn().mockResolvedValue(new Map()), + saveMemories: vi.fn().mockResolvedValue([]), + reinforceMemory: vi.fn().mockResolvedValue(true), + deleteMemory: vi.fn().mockResolvedValue(true), + }; + extractionLockStore = { + getExtractionLock: vi.fn().mockResolvedValue(null), + setExtractionLock: vi.fn().mockResolvedValue('OK'), + }; + logger = { + info: vi.fn(), + warn: vi.fn(), + }; + + service = new MemoryService(memoryPersistenceService as never, extractionLockStore as never, logger); + }); + + it('returns early when extraction lock exists', async () => { + extractionLockStore.getExtractionLock.mockResolvedValue('1'); + + await service.extractMemories('T1', 'C1', 'history', ['U1'], vi.fn(), vi.fn()); + + expect(logger.info).toHaveBeenCalled(); + }); + + it('does nothing when extractor returns NONE', async () => { + const extractFromConversation = vi.fn().mockResolvedValue('NONE'); + + await service.extractMemories('T1', 'C1', 'history', ['U1'], extractFromConversation, vi.fn()); + + expect(memoryPersistenceService.saveMemories).not.toHaveBeenCalled(); + }); + + it('processes NEW, REINFORCE, and EVOLVE extraction modes', async () => { + const extractFromConversation = vi.fn().mockResolvedValue( + JSON.stringify([ + { slackId: 'U123ABC', content: 'new memory', mode: 'NEW' }, + { slackId: 'U123ABC', content: 'reinforce memory', mode: 'REINFORCE', existingMemoryId: 10 }, + { slackId: 'U123ABC', content: 'evolved memory', mode: 'EVOLVE', existingMemoryId: 11 }, + ]), + ); + const regenerateTraitsForUsers = vi.fn().mockResolvedValue(undefined); + + await service.extractMemories( + 'T1', + 'C1', + 'history', + ['U123ABC'], + extractFromConversation, + regenerateTraitsForUsers, + ); + + expect(memoryPersistenceService.saveMemories).toHaveBeenCalled(); + expect(memoryPersistenceService.reinforceMemory).toHaveBeenCalledWith(10); + expect(memoryPersistenceService.deleteMemory).toHaveBeenCalledWith(11); + expect(regenerateTraitsForUsers).toHaveBeenCalledWith('T1', ['U123ABC']); + }); + + it('skips malformed extraction items and logs warnings', async () => { + const extractFromConversation = vi + .fn() + .mockResolvedValue( + JSON.stringify([ + { mode: 'NEW' }, + { slackId: 'invalid', content: 'x', mode: 'NEW' }, + { slackId: 'U123ABC', content: 'x', mode: 'UNKNOWN' }, + ]), + ); + + await service.extractMemories('T1', 'C1', 'history', ['U123ABC'], extractFromConversation, vi.fn()); + + expect(logger.warn).toHaveBeenCalled(); + }); +}); diff --git a/packages/backend/src/ai/memory/memory.service.ts b/packages/backend/src/ai/memory/memory.service.ts new file mode 100644 index 00000000..0ecb52ec --- /dev/null +++ b/packages/backend/src/ai/memory/memory.service.ts @@ -0,0 +1,134 @@ +import { MEMORY_EXTRACTION_PROMPT } from '../ai.constants'; +import { MemoryPersistenceService } from './memory.persistence.service'; +import { logger } from '../../shared/logger/logger'; + +interface ExtractionResult { + slackId: string; + content: string; + mode: 'NEW' | 'REINFORCE' | 'EVOLVE'; + existingMemoryId: number | null; +} + +interface ExtractionLockStore { + getExtractionLock(channelId: string, teamId: string): Promise; + setExtractionLock(channelId: string, teamId: string): Promise; +} + +export class MemoryService { + private memoryLogger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void }; + private memoryPersistenceService: MemoryPersistenceService; + private extractionLockStore: ExtractionLockStore; + + constructor( + memoryPersistenceService?: MemoryPersistenceService, + extractionLockStore?: ExtractionLockStore, + memoryLogger?: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void }, + ) { + this.memoryPersistenceService = memoryPersistenceService ?? new MemoryPersistenceService(); + this.extractionLockStore = extractionLockStore ?? { + getExtractionLock: async () => null, + setExtractionLock: async () => null, + }; + this.memoryLogger = memoryLogger ?? logger.child({ module: 'AIMemoryService' }); + } + + public async extractMemories( + teamId: string, + channelId: string, + conversationHistory: string, + participantSlackIds: string[], + extractFromConversation: (prompt: string, input: string) => Promise, + regenerateTraitsForUsers: (teamId: string, slackIds: string[]) => Promise, + ): Promise { + const locked = await this.extractionLockStore.getExtractionLock(channelId, teamId); + if (locked) { + this.memoryLogger.info(`Extraction lock active for ${channelId}-${teamId}, skipping`); + return; + } + await this.extractionLockStore.setExtractionLock(channelId, teamId); + + try { + const existingMemoriesMap = await this.memoryPersistenceService.getAllMemoriesForUsers( + participantSlackIds, + teamId, + ); + + const existingMemoriesText = + existingMemoriesMap.size > 0 + ? Array.from(existingMemoriesMap.entries()) + .map(([slackId, memories]) => { + const lines = memories.map((m) => ` [ID:${m.id}] "${m.content}"`).join('\n'); + return `${slackId}:\n${lines}`; + }) + .join('\n\n') + : '(no existing memories)'; + + const prompt = MEMORY_EXTRACTION_PROMPT.replace('{existing_memories}', existingMemoriesText); + const result = await extractFromConversation(prompt, conversationHistory); + + if (!result) { + this.memoryLogger.warn('Extraction returned no result'); + return; + } + + const trimmed = result.trim(); + if (trimmed === 'NONE' || trimmed === '"NONE"') return; + + let extractions: Array>; + try { + const parsed: unknown = JSON.parse(trimmed); + extractions = Array.isArray(parsed) ? parsed : [parsed]; + } catch { + this.memoryLogger.warn(`Extraction returned malformed JSON: ${trimmed}`); + return; + } + + const touchedUsers = new Set(); + + for (const extraction of extractions) { + if (!extraction.slackId || !extraction.content || !extraction.mode) { + this.memoryLogger.warn('Extraction missing required fields, skipping:', extraction); + continue; + } + + if (!/^U[A-Z0-9]+$/.test(extraction.slackId)) { + this.memoryLogger.warn(`Invalid slackId format: ${extraction.slackId}`); + continue; + } + + 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.memoryLogger.warn('REINFORCE extraction missing existingMemoryId, skipping'); + } + break; + + case 'EVOLVE': + if (extraction.existingMemoryId) { + await this.memoryPersistenceService.deleteMemory(extraction.existingMemoryId); + } + await this.memoryPersistenceService.saveMemories(extraction.slackId, teamId, [extraction.content]); + touchedUsers.add(extraction.slackId); + break; + + default: + this.memoryLogger.warn(`Unknown extraction mode: ${String(extraction.mode)}`); + } + } + + await regenerateTraitsForUsers(teamId, [...touchedUsers]); + + this.memoryLogger.info(`Extraction complete for ${channelId}: ${extractions.length} observations processed`); + } catch (e) { + this.memoryLogger.warn('Memory extraction failed:', e); + } + } +} diff --git a/packages/backend/src/ai/trait/trait.service.spec.ts b/packages/backend/src/ai/trait/trait.service.spec.ts new file mode 100644 index 00000000..5b740e63 --- /dev/null +++ b/packages/backend/src/ai/trait/trait.service.spec.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { TraitService } from './trait.service'; + +describe('AITraitService', () => { + let traitPersistenceService: { + getAllTraitsForUsers: ReturnType; + replaceTraitsForUser: ReturnType; + }; + let memoryPersistenceService: { + getAllMemoriesForUser: ReturnType; + }; + let logger: { + warn: ReturnType; + }; + let service: TraitService; + + beforeEach(() => { + traitPersistenceService = { + getAllTraitsForUsers: vi.fn().mockResolvedValue(new Map()), + replaceTraitsForUser: vi.fn().mockResolvedValue([]), + }; + memoryPersistenceService = { + getAllMemoriesForUser: vi.fn().mockResolvedValue([]), + }; + logger = { + warn: vi.fn(), + }; + + service = new TraitService(traitPersistenceService as never, memoryPersistenceService as never, logger); + }); + + it('extracts participant ids with include and exclude rules', () => { + const ids = service.extractParticipantSlackIds( + [ + { slackId: 'U1', name: 'A', message: 'm1' } as never, + { slackId: 'U2', name: 'B', message: 'm2' } as never, + { slackId: 'U2', name: 'B', message: 'm3' } as never, + ], + { includeSlackId: 'U3', excludeSlackIds: ['U1'] }, + ); + + expect(ids).toEqual(['U2', 'U3']); + }); + + it('formats trait context grouped by participant name', () => { + const text = service.formatTraitContext( + [ + { slackId: 'U1', content: 'prefers typescript' } as never, + { slackId: 'U2', content: 'dislikes donald trump' } as never, + ], + [ + { slackId: 'U1', name: 'Alice', message: 'hi' } as never, + { slackId: 'U2', name: 'Bob', message: 'hello' } as never, + ], + ); + + expect(text).toContain('traits_context'); + expect(text).toContain('Alice'); + expect(text).toContain('prefers typescript'); + expect(text).toContain('Bob'); + }); + + it('returns base instructions when there is no trait context', () => { + expect(service.appendTraitContext('base', '')).toBe('base'); + }); + + it('inserts trait context before verification section', () => { + const base = 'instructions\n\nchecklist\n'; + const context = '\ntest trait\n'; + + const result = service.appendTraitContext(base, context); + + expect(result).toContain('test trait'); + expect(result.indexOf('traits_context')).toBeLessThan(result.indexOf('')); + }); + + it('fetches trait context from persistence layer', async () => { + traitPersistenceService.getAllTraitsForUsers.mockResolvedValue( + new Map([['U1', [{ slackId: 'U1', content: 'prefers typescript' }]]]), + ); + + const context = await service.fetchTraitContext(['U1'], 'T1', [{ slackId: 'U1', name: 'Alice' } as never]); + + expect(context).toContain('prefers typescript'); + }); + + it('parses, de-duplicates, and caps extracted traits', () => { + const traits = service.parseTraitExtractionResult( + JSON.stringify([...Array.from({ length: 12 }, (_, i) => `trait-${i}`), 'trait-1']), + ); + + expect(traits).toHaveLength(10); + expect(new Set(traits).size).toBe(10); + }); + + it('returns empty traits for malformed extraction payload', () => { + expect(service.parseTraitExtractionResult('{bad')).toEqual([]); + expect(logger.warn).toHaveBeenCalled(); + }); + + it('regenerates traits for users from memories', async () => { + memoryPersistenceService.getAllMemoriesForUser + .mockResolvedValueOnce([{ content: 'JR-15 loves TypeScript' }]) + .mockResolvedValueOnce([]); + + const synthesizeTraits = vi.fn().mockResolvedValue(JSON.stringify(['JR-15 prefers TypeScript'])); + + await service.regenerateTraitsForUsers('T1', ['U1', 'U2'], synthesizeTraits); + + expect(traitPersistenceService.replaceTraitsForUser).toHaveBeenCalledWith('U1', 'T1', ['JR-15 prefers TypeScript']); + expect(traitPersistenceService.replaceTraitsForUser).toHaveBeenCalledWith('U2', 'T1', []); + }); +}); diff --git a/packages/backend/src/ai/trait/trait.service.ts b/packages/backend/src/ai/trait/trait.service.ts new file mode 100644 index 00000000..fc41e316 --- /dev/null +++ b/packages/backend/src/ai/trait/trait.service.ts @@ -0,0 +1,164 @@ +import type { MessageWithName } from '../../shared/models/message/message-with-name'; +import type { TraitWithSlackId } from '../../shared/db/models/Trait'; +import { TraitPersistenceService } from './trait.persistence.service'; +import { MemoryPersistenceService } from '../memory/memory.persistence.service'; +import { logger } from '../../shared/logger/logger'; + +export class TraitService { + private traitLogger: { warn: (...args: unknown[]) => void }; + private traitPersistenceService: TraitPersistenceService; + private memoryPersistenceService: MemoryPersistenceService; + + constructor( + traitPersistenceService?: TraitPersistenceService, + memoryPersistenceService?: MemoryPersistenceService, + traitLogger?: { warn: (...args: unknown[]) => void }, + ) { + this.traitPersistenceService = traitPersistenceService ?? new TraitPersistenceService(); + this.memoryPersistenceService = memoryPersistenceService ?? new MemoryPersistenceService(); + this.traitLogger = traitLogger ?? logger.child({ module: 'AITraitService' }); + } + + public 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 trait of traits) { + const slackId = trait.slackId || 'unknown'; + if (!grouped.has(slackId)) grouped.set(slackId, []); + grouped.get(slackId)!.push(trait); + } + + const lines = Array.from(grouped.entries()) + .map(([slackId, userTraits]) => { + const name = nameMap.get(slackId) || slackId; + const traitLines = userTraits.map((trait) => `"${trait.content}"`).join(', '); + return `- ${name}: ${traitLines}`; + }) + .join('\n'); + + return `\ncore beliefs and stable traits for people in this conversation:\n${lines}\n`; + } + + public extractParticipantSlackIds( + history: MessageWithName[], + options?: { includeSlackId?: string; excludeSlackIds?: string[] }, + ): string[] { + const excludeSet = new Set(options?.excludeSlackIds || []); + const ids = [ + ...new Set(history.filter((msg) => msg.slackId && !excludeSet.has(msg.slackId!)).map((msg) => msg.slackId!)), + ]; + if (options?.includeSlackId && !ids.includes(options.includeSlackId)) { + ids.push(options.includeSlackId); + } + return ids; + } + + public async fetchTraitContext( + participantSlackIds: string[], + teamId: string, + history: MessageWithName[], + ): Promise { + if (participantSlackIds.length === 0) return ''; + const traitsMap = await this.traitPersistenceService.getAllTraitsForUsers(participantSlackIds, teamId); + const traits = Array.from(traitsMap.values()).flat(); + return this.formatTraitContext(traits, history); + } + + public appendTraitContext(baseInstructions: string, traitContext: string): string { + if (!traitContext) return baseInstructions; + + // Insert trait data before so the checklist remains the last thing the model sees. + const verificationTag = ''; + const insertionPoint = baseInstructions.lastIndexOf(verificationTag); + if (insertionPoint !== -1) { + return `${baseInstructions.slice(0, insertionPoint)}${traitContext}\n\n${baseInstructions.slice(insertionPoint)}`; + } + + return `${baseInstructions}\n\n${traitContext}`; + } + + public 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.traitLogger.warn(`Trait extraction returned malformed JSON: ${raw}`); + return []; + } + } + + public async regenerateTraitsForUsers( + teamId: string, + slackIds: string[], + synthesizeTraits: (input: string) => Promise, + ): 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 synthesizeTraits(input).catch((error) => { + this.traitLogger.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 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); + } +} diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index b9108f77..cc9ae4cb 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -110,7 +110,7 @@ app.use('/quote', quoteController); app.use('/rep', reactionController); app.use('/store', storeController); app.use('/summary', summaryController); -app.use('traits', traitController); +app.use('/traits', traitController); app.use('/walkie', walkieController); const slackService = new SlackService(); From 340468d40f22232d9bc7cc7ea0499e458b5c1ed7 Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Mon, 20 Apr 2026 11:30:58 -0400 Subject: [PATCH 04/18] Improved separation of concerns --- packages/backend/src/ai/ai.constants.ts | 1 - packages/backend/src/ai/ai.service.spec.ts | 38 ----- packages/backend/src/ai/ai.service.ts | 51 ------- packages/backend/src/ai/memory/memory.job.ts | 116 +++++++++++++++ packages/backend/src/ai/trait/trait.job.ts | 72 +++++++++ packages/backend/src/index.ts | 36 +---- packages/backend/src/job.service.ts | 148 +++++++++++++++++++ 7 files changed, 339 insertions(+), 123 deletions(-) create mode 100644 packages/backend/src/ai/memory/memory.job.ts create mode 100644 packages/backend/src/ai/trait/trait.job.ts create mode 100644 packages/backend/src/job.service.ts diff --git a/packages/backend/src/ai/ai.constants.ts b/packages/backend/src/ai/ai.constants.ts index df93493a..d4410d46 100644 --- a/packages/backend/src/ai/ai.constants.ts +++ b/packages/backend/src/ai/ai.constants.ts @@ -151,7 +151,6 @@ Do NOT include: 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. diff --git a/packages/backend/src/ai/ai.service.spec.ts b/packages/backend/src/ai/ai.service.spec.ts index cddf0e99..37673526 100644 --- a/packages/backend/src/ai/ai.service.spec.ts +++ b/packages/backend/src/ai/ai.service.spec.ts @@ -741,44 +741,6 @@ describe('AIService', () => { }); }); - describe('extractMemoriesForChannel', () => { - it('returns early when there are no messages in the last 24 hours', async () => { - (aiService.historyService.getLast24HoursForChannel as Mock).mockResolvedValue([]); - - await aiService.extractMemoriesForChannel('T1', 'C1'); - - expect(aiService.openAi.responses.create).not.toHaveBeenCalled(); - }); - - it('returns early when there are no non-Moonbeam participants', async () => { - (aiService.historyService.getLast24HoursForChannel as Mock).mockResolvedValue([ - { slackId: MOONBEAM_SLACK_ID, name: 'Moonbeam', message: 'Hello there' }, - ]); - - await aiService.extractMemoriesForChannel('T1', 'C1'); - - expect(aiService.openAi.responses.create).not.toHaveBeenCalled(); - }); - - it('calls extractMemories with formatted history when valid messages exist', async () => { - (aiService.historyService.getLast24HoursForChannel as Mock).mockResolvedValue([ - { slackId: 'U1', name: 'Alice', message: 'Hello' }, - { slackId: MOONBEAM_SLACK_ID, name: 'Moonbeam', message: 'Hi Alice' }, - ]); - (aiService.redis.getExtractionLock as Mock).mockResolvedValue('1'); - - await aiService.extractMemoriesForChannel('T1', 'C1'); - - expect(aiService.historyService.getLast24HoursForChannel).toHaveBeenCalledWith('T1', 'C1'); - }); - - it('propagates errors from getLast24HoursForChannel', async () => { - (aiService.historyService.getLast24HoursForChannel as Mock).mockRejectedValue(new Error('DB error')); - - await expect(aiService.extractMemoriesForChannel('T1', 'C1')).rejects.toThrow('DB error'); - }); - }); - describe('send helpers', () => { it('sendImage posts image block and fallback on slack error', async () => { const sendMock = aiService.webService.sendMessage as Mock; diff --git a/packages/backend/src/ai/ai.service.ts b/packages/backend/src/ai/ai.service.ts index 5836a572..90ebb6b8 100644 --- a/packages/backend/src/ai/ai.service.ts +++ b/packages/backend/src/ai/ai.service.ts @@ -17,9 +17,7 @@ import { MAX_AI_REQUESTS_PER_DAY, REDPLOY_MOONBEAM_IMAGE_PROMPT, REDPLOY_MOONBEAM_TEXT_PROMPT, - GATE_MODEL, MOONBEAM_SLACK_ID, - TRAIT_EXTRACTION_PROMPT, GPT_MODEL, } from './ai.constants'; import { TraitService } from './trait/trait.service'; @@ -523,19 +521,6 @@ export class AIService { }); } - public async extractMemoriesForChannel(teamId: string, channelId: string): Promise { - const historyMessages = await this.historyService.getLast24HoursForChannel(teamId, channelId); - if (historyMessages.length === 0) return; - - const history = this.formatHistory(historyMessages); - const participantSlackIds = this.traitService.extractParticipantSlackIds(historyMessages, { - excludeSlackIds: [MOONBEAM_SLACK_ID], - }); - if (participantSlackIds.length === 0) return; - - await this.extractMemories(teamId, channelId, history, participantSlackIds); - } - private async updateMoonbeamProfilePhoto(imageBytes: Buffer): Promise { const profileImage = await sharp(imageBytes) .resize(512, 512, { fit: 'cover', position: 'centre' }) @@ -646,42 +631,6 @@ export class AIService { }; } - private async regenerateTraitsForUsers(teamId: string, slackIds: string[]): Promise { - return this.traitService.regenerateTraitsForUsers(teamId, slackIds, async (input) => { - return this.openAi.responses - .create({ - model: GATE_MODEL, - instructions: TRAIT_EXTRACTION_PROMPT, - input, - }) - .then((response) => extractAndParseOpenAiResponse(response)); - }); - } - - private async extractMemories( - teamId: string, - channelId: string, - conversationHistory: string, - participantSlackIds: string[], - ): Promise { - return this.memoryService.extractMemories( - teamId, - channelId, - conversationHistory, - participantSlackIds, - async (prompt, input) => { - return this.openAi.responses - .create({ - model: GATE_MODEL, - instructions: prompt, - input, - }) - .then((x) => extractAndParseOpenAiResponse(x)); - }, - async (regenTeamId, slackIds) => this.regenerateTraitsForUsers(regenTeamId, slackIds), - ); - } - sendImage(image: string | undefined, userId: string, teamId: string, channel: string, text: string): void { if (image) { const blocks: KnownBlock[] = [ diff --git a/packages/backend/src/ai/memory/memory.job.ts b/packages/backend/src/ai/memory/memory.job.ts new file mode 100644 index 00000000..c0f0690b --- /dev/null +++ b/packages/backend/src/ai/memory/memory.job.ts @@ -0,0 +1,116 @@ +import { getRepository } from 'typeorm'; +import { SlackChannel } from '../../shared/db/models/SlackChannel'; +import { HistoryPersistenceService } from '../../shared/services/history.persistence.service'; +import { MemoryService } from './memory.service'; +import { TraitService } from '../trait/trait.service'; +import { AIService } from '../ai.service'; +import { logger } from '../../shared/logger/logger'; +import { DAILY_MEMORY_JOB_CONCURRENCY, GATE_MODEL, TRAIT_EXTRACTION_PROMPT } from '../ai.constants'; +import { MOONBEAM_SLACK_ID } from '../ai.constants'; +import type OpenAI from 'openai'; + +const extractAndParseOpenAiResponse = (response: OpenAI.Responses.Response): string | undefined => { + const textBlock = response.output.find((item) => item.type === 'message'); + if (textBlock && 'content' in textBlock) { + const outputText = textBlock.content.find((item) => item.type === 'output_text'); + return outputText?.text.trim(); + } + return undefined; +}; + +export class MemoryJob { + private historyService = new HistoryPersistenceService(); + private memoryService = new MemoryService(); + private traitService = new TraitService(); + private aiService: AIService; + private jobLogger = logger.child({ module: 'MemoryJob' }); + + constructor(aiService?: AIService) { + this.aiService = aiService ?? new AIService(); + } + + async run(): Promise { + this.jobLogger.info('Starting memory extraction job'); + + const channels = await getRepository(SlackChannel).find(); + + const results = await this.runWithConcurrencyLimit( + channels.map((channel) => () => this.extractMemoriesForChannel(channel.teamId, channel.channelId)), + DAILY_MEMORY_JOB_CONCURRENCY, + ); + + const failed = results + .map((result, index) => ({ result, index })) + .filter((item): item is { result: PromiseRejectedResult; index: number } => item.result.status === 'rejected'); + failed.forEach(({ result, index }) => { + const channel = channels[index]; + this.jobLogger.warn( + `Failed to extract memories for channel ${channel.channelId} (team ${channel.teamId}):`, + result.reason, + ); + }); + + const processed = results.length - failed.length; + this.jobLogger.info(`Memory extraction job complete: processed ${processed}/${channels.length} channels`); + } + + private async extractMemoriesForChannel(teamId: string, channelId: string): Promise { + const historyMessages = await this.historyService.getLast24HoursForChannel(teamId, channelId); + if (historyMessages.length === 0) return; + + const history = this.aiService.formatHistory(historyMessages); + const participantSlackIds = this.traitService.extractParticipantSlackIds(historyMessages, { + excludeSlackIds: [MOONBEAM_SLACK_ID], + }); + if (participantSlackIds.length === 0) return; + + await this.memoryService.extractMemories( + teamId, + channelId, + history, + participantSlackIds, + async (prompt, input) => { + return this.aiService.openAi.responses + .create({ + model: GATE_MODEL, + instructions: prompt, + input, + }) + .then((x) => extractAndParseOpenAiResponse(x)); + }, + async (regenTeamId, slackIds) => { + await this.traitService.regenerateTraitsForUsers(regenTeamId, slackIds, async (input) => { + return this.aiService.openAi.responses + .create({ + model: GATE_MODEL, + instructions: TRAIT_EXTRACTION_PROMPT, + input, + }) + .then((response) => extractAndParseOpenAiResponse(response)); + }); + }, + ); + } + + private async runWithConcurrencyLimit( + tasks: Array<() => Promise>, + concurrency: number, + ): Promise[]> { + const results: PromiseSettledResult[] = new Array(tasks.length); + let nextIndex = 0; + + const runNext = async (): Promise => { + while (nextIndex < tasks.length) { + const index = nextIndex++; + try { + results[index] = { status: 'fulfilled', value: await tasks[index]() }; + } catch (reason) { + results[index] = { status: 'rejected', reason }; + } + } + }; + + await Promise.all(Array.from({ length: Math.min(concurrency, tasks.length) }, () => runNext())); + return results; + } +} diff --git a/packages/backend/src/ai/trait/trait.job.ts b/packages/backend/src/ai/trait/trait.job.ts new file mode 100644 index 00000000..456c93fa --- /dev/null +++ b/packages/backend/src/ai/trait/trait.job.ts @@ -0,0 +1,72 @@ +import { SlackUser } from '../../shared/db/models/SlackUser'; +import { getRepository } from 'typeorm'; +import { TraitService } from './trait.service'; +import { AIService } from '../ai.service'; +import { logger } from '../../shared/logger/logger'; +import { TRAIT_EXTRACTION_PROMPT } from '../ai.constants'; + +export class TraitJob { + private traitService: TraitService; + private aiService: AIService; + private jobLogger = logger.child({ module: 'TraitJob' }); + + constructor(traitService?: TraitService, aiService?: AIService) { + this.traitService = traitService ?? new TraitService(); + this.aiService = aiService ?? new AIService(); + } + + async run(): Promise { + this.jobLogger.info('Starting trait regeneration job'); + + try { + // Get all users + const users = await getRepository(SlackUser).find(); + + if (users.length === 0) { + this.jobLogger.info('No users found for trait regeneration'); + return; + } + + // Extract all team IDs to regenerate traits for + const teamIds = Array.from(new Set(users.map((u) => u.teamId))); + + let totalUsers = 0; + let processedUsers = 0; + + for (const teamId of teamIds) { + const teamUsers = users.filter((u) => u.teamId === teamId); + totalUsers += teamUsers.length; + + const slackIds = teamUsers.map((u) => u.slackId); + + try { + await this.traitService.regenerateTraitsForUsers(teamId, slackIds, async (input) => { + return this.aiService.openAi.responses + .create({ + model: 'gpt-4o-mini', + instructions: TRAIT_EXTRACTION_PROMPT, + input, + user: `trait-job-${teamId}`, + }) + .then((response) => { + const textBlock = response.output.find((item) => item.type === 'message'); + if (textBlock && 'content' in textBlock) { + const outputText = textBlock.content.find((item) => item.type === 'output_text'); + return outputText?.text.trim(); + } + return undefined; + }); + }); + processedUsers += slackIds.length; + } catch (error) { + this.jobLogger.warn(`Failed to regenerate traits for team ${teamId}:`, error); + } + } + + this.jobLogger.info(`Trait regeneration job complete: processed ${processedUsers}/${totalUsers} users`); + } catch (error) { + this.jobLogger.error('Trait regeneration job failed:', error); + throw error; + } + } +} diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index cc9ae4cb..8cf24c1c 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -2,7 +2,6 @@ import 'reflect-metadata'; // Necessary for TypeORM entities. import 'dotenv/config'; import bodyParser from 'body-parser'; import cors from 'cors'; -import cron from 'node-cron'; import type { Application } from 'express'; import express from 'express'; @@ -30,9 +29,7 @@ import { signatureVerificationMiddleware } from './shared/middleware/signatureVe import { WebService } from './shared/services/web/web.service'; import { logger } from './shared/logger/logger'; import { AIService } from './ai/ai.service'; -import { DailyMemoryJob } from './ai/daily-memory.job'; -import { FunFactJob } from './jobs/fun-fact.job'; -import { PricingJob } from './jobs/pricing.job'; +import { JobService } from './job.service'; import { resolveTypeOrmEntities } from './shared/db/typeorm-options'; import { portfolioController } from './portfolio/portfolio.controller'; import { hookController } from './hook/hook.controller'; @@ -116,9 +113,7 @@ app.use('/walkie', walkieController); const slackService = new SlackService(); const webService = new WebService(); const aiService = new AIService(); -const dailyMemoryJob = new DailyMemoryJob(aiService); -const funFactJob = new FunFactJob(); -const pricingJob = new PricingJob(); +const jobService = new JobService(aiService); const indexLogger = logger.child({ module: 'Index' }); const connectToDb = async (): Promise => { @@ -200,32 +195,7 @@ app.listen(PORT, (e?: Error) => { } else { indexLogger.info('Database connection established successfully.'); void aiService.redeployMoonbeam(); - cron.schedule( - '0 3 * * *', - () => { - void dailyMemoryJob.run(); - }, - { timezone: 'America/New_York' }, - ); - indexLogger.info('Daily memory extraction job scheduled daily at 3AM America/New_York time.'); - cron.schedule( - '0 9 * * *', - () => { - void funFactJob.run(); - }, - { timezone: 'America/New_York' }, - ); - indexLogger.info('Fun-fact job scheduled daily at 9AM America/New_York time.'); - cron.schedule( - '10 * * * *', - () => { - void pricingJob.run().catch((error) => { - indexLogger.error('Pricing job failed:', error); - }); - }, - { timezone: 'America/New_York' }, - ); - indexLogger.info('Pricing job scheduled every hour at minute 10 America/New_York time.'); + jobService.scheduleCronJobs(); } }) .catch((error) => { diff --git a/packages/backend/src/job.service.ts b/packages/backend/src/job.service.ts new file mode 100644 index 00000000..216fa3d1 --- /dev/null +++ b/packages/backend/src/job.service.ts @@ -0,0 +1,148 @@ +import cron from 'node-cron'; +import { MemoryJob } from './ai/memory/memory.job'; +import { TraitJob } from './ai/trait/trait.job'; +import { FunFactJob } from './jobs/fun-fact.job'; +import { PricingJob } from './jobs/pricing.job'; +import { logger } from './shared/logger/logger'; + +export class JobService { + private memoryJob: MemoryJob; + private traitJob: TraitJob; + private funFactJob: FunFactJob; + private pricingJob: PricingJob; + private jobServiceLogger = logger.child({ module: 'JobService' }); + + constructor() { + this.memoryJob = new MemoryJob(); + this.traitJob = new TraitJob(); + this.funFactJob = new FunFactJob(); + this.pricingJob = new PricingJob(); + } + + /** + * Run the memory and trait jobs in sequence. + * Memory job runs first, then trait job runs only if memory job succeeds. + */ + async runMemoryAndTraitJobs(): Promise { + this.jobServiceLogger.info('Starting memory and trait job sequence'); + + try { + // Run memory job first + this.jobServiceLogger.info('Running memory job...'); + await this.memoryJob.run(); + this.jobServiceLogger.info('Memory job succeeded, proceeding with trait job'); + + // Run trait job only if memory job succeeds + this.jobServiceLogger.info('Running trait job...'); + await this.traitJob.run(); + this.jobServiceLogger.info('Trait job succeeded'); + + this.jobServiceLogger.info('Memory and trait job sequence completed successfully'); + } catch (error) { + this.jobServiceLogger.error('Memory and trait job sequence failed:', error); + throw error; + } + } + + /** + * Run the fun fact job + */ + async runFunFactJob(): Promise { + this.jobServiceLogger.info('Running fun fact job'); + try { + await this.funFactJob.run(); + this.jobServiceLogger.info('Fun fact job completed successfully'); + } catch (error) { + this.jobServiceLogger.error('Fun fact job failed:', error); + throw error; + } + } + + /** + * Run the pricing job + */ + async runPricingJob(): Promise { + this.jobServiceLogger.info('Running pricing job'); + try { + await this.pricingJob.run(); + this.jobServiceLogger.info('Pricing job completed successfully'); + } catch (error) { + this.jobServiceLogger.error('Pricing job failed:', error); + throw error; + } + } + + /** + * Run the memory job in isolation + */ + async runMemoryJob(): Promise { + this.jobServiceLogger.info('Running memory job in isolation'); + try { + await this.memoryJob.run(); + this.jobServiceLogger.info('Memory job completed successfully'); + } catch (error) { + this.jobServiceLogger.error('Memory job failed:', error); + throw error; + } + } + + /** + * Run the trait job in isolation + */ + async runTraitJob(): Promise { + this.jobServiceLogger.info('Running trait job in isolation'); + try { + await this.traitJob.run(); + this.jobServiceLogger.info('Trait job completed successfully'); + } catch (error) { + this.jobServiceLogger.error('Trait job failed:', error); + throw error; + } + } + + /** + * Schedule all cron jobs on startup. + * Memory and trait jobs run daily at 3AM. + * Fun fact job runs daily at 9AM. + * Pricing job runs every hour at minute 10. + */ + scheduleCronJobs(): void { + this.jobServiceLogger.info('Scheduling cron jobs'); + + // Memory and trait jobs: daily at 3AM America/New_York + cron.schedule( + '0 3 * * *', + () => { + this.runMemoryAndTraitJobs().catch((error) => { + this.jobServiceLogger.error('Memory and trait job sequence failed:', error); + }); + }, + { timezone: 'America/New_York' }, + ); + this.jobServiceLogger.info('Memory and trait job sequence scheduled daily at 3AM America/New_York time.'); + + // Fun fact job: daily at 9AM America/New_York + cron.schedule( + '0 9 * * *', + () => { + this.runFunFactJob().catch((error) => { + this.jobServiceLogger.error('Fun-fact job failed:', error); + }); + }, + { timezone: 'America/New_York' }, + ); + this.jobServiceLogger.info('Fun-fact job scheduled daily at 9AM America/New_York time.'); + + // Pricing job: every hour at minute 10 America/New_York + cron.schedule( + '10 * * * *', + () => { + this.runPricingJob().catch((error) => { + this.jobServiceLogger.error('Pricing job failed:', error); + }); + }, + { timezone: 'America/New_York' }, + ); + this.jobServiceLogger.info('Pricing job scheduled every hour at minute 10 America/New_York time.'); + } +} From 547546997a9f9540af77cb908f16bfbc51ee0aad Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Mon, 20 Apr 2026 11:33:09 -0400 Subject: [PATCH 05/18] Removed daily-memory job --- .../backend/src/ai/daily-memory.job.spec.ts | 138 ------------------ packages/backend/src/ai/daily-memory.job.ts | 61 -------- 2 files changed, 199 deletions(-) delete mode 100644 packages/backend/src/ai/daily-memory.job.spec.ts delete mode 100644 packages/backend/src/ai/daily-memory.job.ts diff --git a/packages/backend/src/ai/daily-memory.job.spec.ts b/packages/backend/src/ai/daily-memory.job.spec.ts deleted file mode 100644 index 7897b759..00000000 --- a/packages/backend/src/ai/daily-memory.job.spec.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { vi } from 'vitest'; -import { DailyMemoryJob } from './daily-memory.job'; -import type { SlackChannel } from '../shared/db/models/SlackChannel'; -import { DAILY_MEMORY_JOB_CONCURRENCY } from './ai.constants'; - -vi.mock('typeorm', async () => ({ - getRepository: vi.fn().mockReturnValue({ - find: 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(), - debug: vi.fn(), - }), - }, -})); - -import { getRepository } from 'typeorm'; - -const buildJob = (): DailyMemoryJob => { - const job = new DailyMemoryJob(); - - job.aiService = { - extractMemoriesForChannel: vi.fn().mockResolvedValue(undefined), - } as unknown as DailyMemoryJob['aiService']; - - (job as unknown as { jobLogger: Record }).jobLogger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }; - - return job; -}; - -describe('DailyMemoryJob', () => { - let job: DailyMemoryJob; - let findMock: Mock; - - beforeEach(() => { - vi.clearAllMocks(); - (getRepository as Mock).mockReturnValue({ - find: vi.fn(), - }); - job = buildJob(); - findMock = (getRepository as Mock)().find as Mock; - }); - - it('calls extractMemoriesForChannel for each channel', async () => { - const channels: Partial[] = [ - { channelId: 'C1', teamId: 'T1' }, - { channelId: 'C2', teamId: 'T1' }, - ]; - findMock.mockResolvedValue(channels); - - await job.run(); - - expect(job.aiService.extractMemoriesForChannel).toHaveBeenCalledTimes(2); - expect(job.aiService.extractMemoriesForChannel).toHaveBeenCalledWith('T1', 'C1'); - expect(job.aiService.extractMemoriesForChannel).toHaveBeenCalledWith('T1', 'C2'); - }); - - it('continues processing remaining channels when one fails', async () => { - const channels: Partial[] = [ - { channelId: 'C1', teamId: 'T1' }, - { channelId: 'C2', teamId: 'T1' }, - ]; - findMock.mockResolvedValue(channels); - (job.aiService.extractMemoriesForChannel as Mock) - .mockRejectedValueOnce(new Error('fail')) - .mockResolvedValueOnce(undefined); - - await job.run(); - - expect(job.aiService.extractMemoriesForChannel).toHaveBeenCalledTimes(2); - }); - - it('handles an empty channel list gracefully', async () => { - findMock.mockResolvedValue([]); - - await job.run(); - - expect(job.aiService.extractMemoriesForChannel).not.toHaveBeenCalled(); - }); - - it('processes all channels even when count exceeds the concurrency limit', async () => { - const channels: Partial[] = Array.from({ length: DAILY_MEMORY_JOB_CONCURRENCY + 2 }, (_, i) => ({ - channelId: `C${i}`, - teamId: 'T1', - })); - findMock.mockResolvedValue(channels); - - await job.run(); - - expect(job.aiService.extractMemoriesForChannel).toHaveBeenCalledTimes(channels.length); - }); - - it('processes channels with a sliding window so at most DAILY_MEMORY_JOB_CONCURRENCY run at once', async () => { - const channels: Partial[] = Array.from({ length: DAILY_MEMORY_JOB_CONCURRENCY + 1 }, (_, i) => ({ - channelId: `C${i}`, - teamId: 'T1', - })); - findMock.mockResolvedValue(channels); - - let maxInflight = 0; - let currentInflight = 0; - (job.aiService.extractMemoriesForChannel as Mock).mockImplementation( - () => - new Promise((resolve) => { - currentInflight++; - if (currentInflight > maxInflight) maxInflight = currentInflight; - setImmediate(() => { - currentInflight--; - resolve(); - }); - }), - ); - - await job.run(); - - expect(maxInflight).toBeLessThanOrEqual(DAILY_MEMORY_JOB_CONCURRENCY); - expect(job.aiService.extractMemoriesForChannel).toHaveBeenCalledTimes(channels.length); - }); -}); diff --git a/packages/backend/src/ai/daily-memory.job.ts b/packages/backend/src/ai/daily-memory.job.ts deleted file mode 100644 index c9737ae4..00000000 --- a/packages/backend/src/ai/daily-memory.job.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { getRepository } from 'typeorm'; -import { SlackChannel } from '../shared/db/models/SlackChannel'; -import { AIService } from './ai.service'; -import { logger } from '../shared/logger/logger'; -import { DAILY_MEMORY_JOB_CONCURRENCY } from './ai.constants'; - -export class DailyMemoryJob { - aiService: AIService; - private jobLogger = logger.child({ module: 'DailyMemoryJob' }); - - constructor(aiService?: AIService) { - this.aiService = aiService ?? new AIService(); - } - - async run(): Promise { - this.jobLogger.info('Starting daily memory extraction job'); - - const channels = await getRepository(SlackChannel).find(); - - const results = await this.runWithConcurrencyLimit( - channels.map((channel) => () => this.aiService.extractMemoriesForChannel(channel.teamId, channel.channelId)), - DAILY_MEMORY_JOB_CONCURRENCY, - ); - - const failed = results - .map((result, index) => ({ result, index })) - .filter((item): item is { result: PromiseRejectedResult; index: number } => item.result.status === 'rejected'); - failed.forEach(({ result, index }) => { - const channel = channels[index]; - this.jobLogger.warn( - `Failed to extract memories for channel ${channel.channelId} (team ${channel.teamId}):`, - result.reason, - ); - }); - - const processed = results.length - failed.length; - this.jobLogger.info(`Daily memory extraction job complete: processed ${processed}/${channels.length} channels`); - } - - private async runWithConcurrencyLimit( - tasks: Array<() => Promise>, - concurrency: number, - ): Promise[]> { - const results: PromiseSettledResult[] = new Array(tasks.length); - let nextIndex = 0; - - const runNext = async (): Promise => { - while (nextIndex < tasks.length) { - const index = nextIndex++; - try { - results[index] = { status: 'fulfilled', value: await tasks[index]() }; - } catch (reason) { - results[index] = { status: 'rejected', reason }; - } - } - }; - - await Promise.all(Array.from({ length: Math.min(concurrency, tasks.length) }, () => runNext())); - return results; - } -} From 284b6fc2fbdda4532be9df006582445c640ab697 Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Mon, 20 Apr 2026 11:33:56 -0400 Subject: [PATCH 06/18] Fixed build --- packages/backend/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 8cf24c1c..a9b61749 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -113,7 +113,7 @@ app.use('/walkie', walkieController); const slackService = new SlackService(); const webService = new WebService(); const aiService = new AIService(); -const jobService = new JobService(aiService); +const jobService = new JobService(); const indexLogger = logger.child({ module: 'Index' }); const connectToDb = async (): Promise => { From fb7a5f7f41b1171118d3edb394c43d3dcbbbd271 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:45:07 +0000 Subject: [PATCH 07/18] Fix concurrency bug, MemoryJob lock injection, JobService wiring, and spec type errors Agent-Logs-Url: https://github.com/dev-chat/mocker/sessions/4ef0db0a-b3f1-4118-9dbc-4620e8e94767 Co-authored-by: sfreeman422 <16405652+sfreeman422@users.noreply.github.com> --- packages/backend/src/ai/ai.service.spec.ts | 16 ++++++++-------- packages/backend/src/ai/memory/memory.job.ts | 3 ++- packages/backend/src/ai/trait/trait.service.ts | 14 ++++++++------ packages/backend/src/index.ts | 2 +- packages/backend/src/job.service.ts | 7 ++++--- 5 files changed, 23 insertions(+), 19 deletions(-) diff --git a/packages/backend/src/ai/ai.service.spec.ts b/packages/backend/src/ai/ai.service.spec.ts index 37673526..910c75cf 100644 --- a/packages/backend/src/ai/ai.service.spec.ts +++ b/packages/backend/src/ai/ai.service.spec.ts @@ -37,18 +37,18 @@ const buildAiService = (): AIService => { }, } as unknown as AIService['gemini']; - ai.memoryPersistenceService = { + const 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']; + } as never; - ai.traitPersistenceService = { + const traitPersistenceService = { getAllTraitsForUsers: vi.fn().mockResolvedValue(new Map()), replaceTraitsForUser: vi.fn().mockResolvedValue([]), - } as unknown as AIService['traitPersistenceService']; + } as never; ai.historyService = { getHistory: vi.fn().mockResolvedValue([]), @@ -82,8 +82,8 @@ const buildAiService = (): AIService => { } as unknown as AIService['aiServiceLogger']; ai.traitService = new TraitService( - ai.traitPersistenceService as never, - ai.memoryPersistenceService as never, + traitPersistenceService, + memoryPersistenceService, ai.aiServiceLogger as never, ); @@ -360,7 +360,7 @@ describe('AIService', () => { (aiService.historyService.getHistory as Mock).mockResolvedValue([ { name: 'Jane', slackId: 'U2', message: 'Hi there' }, ]); - (aiService.traitPersistenceService.getAllTraitsForUsers as Mock).mockResolvedValue( + ((aiService.traitService as unknown as { traitPersistenceService: { getAllTraitsForUsers: unknown } }).traitPersistenceService.getAllTraitsForUsers as Mock).mockResolvedValue( new Map([['U2', [{ slackId: 'U2', content: 'prefers typescript' }]]]), ); const createSpy = aiService.openAi.responses.create as Mock; @@ -562,7 +562,7 @@ describe('AIService', () => { (aiService.historyService.getHistoryWithOptions as Mock).mockResolvedValue([ { slackId: 'U2', name: 'Jane', message: 'hello' }, ]); - (aiService.traitPersistenceService.getAllTraitsForUsers as Mock).mockResolvedValue( + ((aiService.traitService as unknown as { traitPersistenceService: { getAllTraitsForUsers: unknown } }).traitPersistenceService.getAllTraitsForUsers as Mock).mockResolvedValue( new Map([['U2', [{ slackId: 'U2', content: 'dislikes donald trump' }]]]), ); const createSpy = aiService.openAi.responses.create as Mock; diff --git a/packages/backend/src/ai/memory/memory.job.ts b/packages/backend/src/ai/memory/memory.job.ts index c0f0690b..9fa16f34 100644 --- a/packages/backend/src/ai/memory/memory.job.ts +++ b/packages/backend/src/ai/memory/memory.job.ts @@ -20,13 +20,14 @@ const extractAndParseOpenAiResponse = (response: OpenAI.Responses.Response): str export class MemoryJob { private historyService = new HistoryPersistenceService(); - private memoryService = new MemoryService(); + private memoryService: MemoryService; private traitService = new TraitService(); private aiService: AIService; private jobLogger = logger.child({ module: 'MemoryJob' }); constructor(aiService?: AIService) { this.aiService = aiService ?? new AIService(); + this.memoryService = new MemoryService(undefined, this.aiService.redis); } async run(): Promise { diff --git a/packages/backend/src/ai/trait/trait.service.ts b/packages/backend/src/ai/trait/trait.service.ts index fc41e316..e1287482 100644 --- a/packages/backend/src/ai/trait/trait.service.ts +++ b/packages/backend/src/ai/trait/trait.service.ts @@ -149,14 +149,16 @@ export class TraitService { let nextIndex = 0; const runners = Array.from({ length: effectiveConcurrency }, async () => { - const currentIndex = nextIndex; - nextIndex += 1; + while (true) { + const currentIndex = nextIndex; + nextIndex += 1; - if (currentIndex >= items.length) { - return; - } + if (currentIndex >= items.length) { + return; + } - await worker(items[currentIndex]); + await worker(items[currentIndex]); + } }); await Promise.all(runners); diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index a9b61749..8cf24c1c 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -113,7 +113,7 @@ app.use('/walkie', walkieController); const slackService = new SlackService(); const webService = new WebService(); const aiService = new AIService(); -const jobService = new JobService(); +const jobService = new JobService(aiService); const indexLogger = logger.child({ module: 'Index' }); const connectToDb = async (): Promise => { diff --git a/packages/backend/src/job.service.ts b/packages/backend/src/job.service.ts index 216fa3d1..80591095 100644 --- a/packages/backend/src/job.service.ts +++ b/packages/backend/src/job.service.ts @@ -4,6 +4,7 @@ import { TraitJob } from './ai/trait/trait.job'; import { FunFactJob } from './jobs/fun-fact.job'; import { PricingJob } from './jobs/pricing.job'; import { logger } from './shared/logger/logger'; +import { AIService } from './ai/ai.service'; export class JobService { private memoryJob: MemoryJob; @@ -12,9 +13,9 @@ export class JobService { private pricingJob: PricingJob; private jobServiceLogger = logger.child({ module: 'JobService' }); - constructor() { - this.memoryJob = new MemoryJob(); - this.traitJob = new TraitJob(); + constructor(aiService?: AIService) { + this.memoryJob = new MemoryJob(aiService); + this.traitJob = new TraitJob(undefined, aiService); this.funFactJob = new FunFactJob(); this.pricingJob = new PricingJob(); } From 79c1e4067a910a13030c820f457c261890f8aff1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:47:14 +0000 Subject: [PATCH 08/18] Simplify nested type assertions in trait context tests Agent-Logs-Url: https://github.com/dev-chat/mocker/sessions/4ef0db0a-b3f1-4118-9dbc-4620e8e94767 Co-authored-by: sfreeman422 <16405652+sfreeman422@users.noreply.github.com> --- packages/backend/src/ai/ai.service.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/ai/ai.service.spec.ts b/packages/backend/src/ai/ai.service.spec.ts index 910c75cf..aa4acd5b 100644 --- a/packages/backend/src/ai/ai.service.spec.ts +++ b/packages/backend/src/ai/ai.service.spec.ts @@ -360,7 +360,8 @@ describe('AIService', () => { (aiService.historyService.getHistory as Mock).mockResolvedValue([ { name: 'Jane', slackId: 'U2', message: 'Hi there' }, ]); - ((aiService.traitService as unknown as { traitPersistenceService: { getAllTraitsForUsers: unknown } }).traitPersistenceService.getAllTraitsForUsers as Mock).mockResolvedValue( + const traitPersistenceService = (aiService.traitService as unknown as { traitPersistenceService: { getAllTraitsForUsers: unknown } }).traitPersistenceService; + (traitPersistenceService.getAllTraitsForUsers as Mock).mockResolvedValue( new Map([['U2', [{ slackId: 'U2', content: 'prefers typescript' }]]]), ); const createSpy = aiService.openAi.responses.create as Mock; @@ -562,7 +563,8 @@ describe('AIService', () => { (aiService.historyService.getHistoryWithOptions as Mock).mockResolvedValue([ { slackId: 'U2', name: 'Jane', message: 'hello' }, ]); - ((aiService.traitService as unknown as { traitPersistenceService: { getAllTraitsForUsers: unknown } }).traitPersistenceService.getAllTraitsForUsers as Mock).mockResolvedValue( + const traitPersistenceService = (aiService.traitService as unknown as { traitPersistenceService: { getAllTraitsForUsers: unknown } }).traitPersistenceService; + (traitPersistenceService.getAllTraitsForUsers as Mock).mockResolvedValue( new Map([['U2', [{ slackId: 'U2', content: 'dislikes donald trump' }]]]), ); const createSpy = aiService.openAi.responses.create as Mock; From 9026c8a15511f49ec950118b7fbffeb4fbd51dfe Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Mon, 20 Apr 2026 11:55:18 -0400 Subject: [PATCH 09/18] Better separation of concerns --- packages/backend/src/ai/ai.service.spec.ts | 25 +-- packages/backend/src/ai/ai.service.ts | 7 +- .../ai/helpers/extractParticipantSlacKIds.ts | 15 ++ .../backend/src/ai/memory/memory.job.spec.ts | 169 ++++++++++++++++++ packages/backend/src/ai/memory/memory.job.ts | 159 ++++++++++++---- .../src/ai/memory/memory.service.spec.ts | 96 ---------- .../backend/src/ai/memory/memory.service.ts | 134 -------------- .../backend/src/ai/trait/trait.job.spec.ts | 92 ++++++++++ packages/backend/src/ai/trait/trait.job.ts | 121 ++++++++++--- .../src/ai/trait/trait.service.spec.ts | 56 +----- .../backend/src/ai/trait/trait.service.ts | 107 +---------- 11 files changed, 514 insertions(+), 467 deletions(-) create mode 100644 packages/backend/src/ai/helpers/extractParticipantSlacKIds.ts create mode 100644 packages/backend/src/ai/memory/memory.job.spec.ts delete mode 100644 packages/backend/src/ai/memory/memory.service.spec.ts delete mode 100644 packages/backend/src/ai/memory/memory.service.ts create mode 100644 packages/backend/src/ai/trait/trait.job.spec.ts diff --git a/packages/backend/src/ai/ai.service.spec.ts b/packages/backend/src/ai/ai.service.spec.ts index aa4acd5b..3a11f7e4 100644 --- a/packages/backend/src/ai/ai.service.spec.ts +++ b/packages/backend/src/ai/ai.service.spec.ts @@ -6,7 +6,6 @@ import { AIService } from './ai.service'; import type { MessageWithName } from '../shared/models/message/message-with-name'; import { MOONBEAM_SLACK_ID } from './ai.constants'; import { TraitService } from './trait/trait.service'; -import { MemoryService } from './memory/memory.service'; const buildAiService = (): AIService => { const ai = new AIService(); @@ -37,14 +36,6 @@ const buildAiService = (): AIService => { }, } as unknown as AIService['gemini']; - const 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 never; - const traitPersistenceService = { getAllTraitsForUsers: vi.fn().mockResolvedValue(new Map()), replaceTraitsForUser: vi.fn().mockResolvedValue([]), @@ -81,13 +72,7 @@ const buildAiService = (): AIService => { debug: vi.fn(), } as unknown as AIService['aiServiceLogger']; - ai.traitService = new TraitService( - traitPersistenceService, - memoryPersistenceService, - ai.aiServiceLogger as never, - ); - - ai.memoryService = new MemoryService(); + ai.traitService = new TraitService(traitPersistenceService as never); return ai; }; @@ -360,7 +345,9 @@ describe('AIService', () => { (aiService.historyService.getHistory as Mock).mockResolvedValue([ { name: 'Jane', slackId: 'U2', message: 'Hi there' }, ]); - const traitPersistenceService = (aiService.traitService as unknown as { traitPersistenceService: { getAllTraitsForUsers: unknown } }).traitPersistenceService; + const traitPersistenceService = ( + aiService.traitService as unknown as { traitPersistenceService: { getAllTraitsForUsers: unknown } } + ).traitPersistenceService; (traitPersistenceService.getAllTraitsForUsers as Mock).mockResolvedValue( new Map([['U2', [{ slackId: 'U2', content: 'prefers typescript' }]]]), ); @@ -563,7 +550,9 @@ describe('AIService', () => { (aiService.historyService.getHistoryWithOptions as Mock).mockResolvedValue([ { slackId: 'U2', name: 'Jane', message: 'hello' }, ]); - const traitPersistenceService = (aiService.traitService as unknown as { traitPersistenceService: { getAllTraitsForUsers: unknown } }).traitPersistenceService; + const traitPersistenceService = ( + aiService.traitService as unknown as { traitPersistenceService: { getAllTraitsForUsers: unknown } } + ).traitPersistenceService; (traitPersistenceService.getAllTraitsForUsers as Mock).mockResolvedValue( new Map([['U2', [{ slackId: 'U2', content: 'dislikes donald trump' }]]]), ); diff --git a/packages/backend/src/ai/ai.service.ts b/packages/backend/src/ai/ai.service.ts index 90ebb6b8..06dce35e 100644 --- a/packages/backend/src/ai/ai.service.ts +++ b/packages/backend/src/ai/ai.service.ts @@ -21,7 +21,6 @@ import { GPT_MODEL, } from './ai.constants'; import { TraitService } from './trait/trait.service'; -import { MemoryService } from './memory/memory.service'; import { logError } from '../shared/logger/error-logging'; import { logger } from '../shared/logger/logger'; import { SlackService } from '../shared/services/slack/slack.service'; @@ -37,6 +36,7 @@ import type { import type { Part } from '@google/genai'; import { GoogleGenAI } from '@google/genai'; import sharp from 'sharp'; +import { extractParticipantSlackIds } from './helpers/extractParticipantSlacKIds'; interface ReleaseCommit { sha: string; @@ -95,7 +95,6 @@ export class AIService { webService = new WebService(); slackService = new SlackService(); slackPersistenceService = new SlackPersistenceService(); - memoryService = new MemoryService(); traitService = new TraitService(); aiServiceLogger = logger.child({ module: 'AIService' }); @@ -391,7 +390,7 @@ export class AIService { const normalizedCustomPrompt = customPrompt?.trim() || null; const traitContext = await this.traitService.fetchTraitContext( - this.traitService.extractParticipantSlackIds(history, { includeSlackId: user_id }), + extractParticipantSlackIds(history, { includeSlackId: user_id }), team_id, history, ); @@ -476,7 +475,7 @@ export class AIService { const customPrompt = userId ? await this.slackPersistenceService.getCustomPrompt(userId, teamId) : null; const normalizedCustomPrompt = customPrompt?.trim() || null; - const participantSlackIds = this.traitService.extractParticipantSlackIds(historyMessages, { + const participantSlackIds = extractParticipantSlackIds(historyMessages, { excludeSlackIds: [MOONBEAM_SLACK_ID], }); const traitContext = await this.traitService.fetchTraitContext(participantSlackIds, teamId, historyMessages); diff --git a/packages/backend/src/ai/helpers/extractParticipantSlacKIds.ts b/packages/backend/src/ai/helpers/extractParticipantSlacKIds.ts new file mode 100644 index 00000000..def12d38 --- /dev/null +++ b/packages/backend/src/ai/helpers/extractParticipantSlacKIds.ts @@ -0,0 +1,15 @@ +import type { MessageWithName } from '../../shared/models/message/message-with-name'; + +export const extractParticipantSlackIds = ( + history: MessageWithName[], + options?: { includeSlackId?: string; excludeSlackIds?: string[] }, +): string[] => { + const excludeSet = new Set(options?.excludeSlackIds || []); + const ids = [ + ...new Set(history.filter((msg) => msg.slackId && !excludeSet.has(msg.slackId!)).map((msg) => msg.slackId!)), + ]; + if (options?.includeSlackId && !ids.includes(options.includeSlackId)) { + ids.push(options.includeSlackId); + } + return ids; +}; diff --git a/packages/backend/src/ai/memory/memory.job.spec.ts b/packages/backend/src/ai/memory/memory.job.spec.ts new file mode 100644 index 00000000..741dd87a --- /dev/null +++ b/packages/backend/src/ai/memory/memory.job.spec.ts @@ -0,0 +1,169 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { MemoryJob } from './memory.job'; + +describe('MemoryJob', () => { + let job: MemoryJob; + let memoryPersistenceService: { + getAllMemoriesForUsers: ReturnType; + saveMemories: ReturnType; + reinforceMemory: ReturnType; + deleteMemory: ReturnType; + }; + let redis: { + getValue: ReturnType; + setValueWithExpire: ReturnType; + }; + let traitJob: { + runForUsers: ReturnType; + }; + let jobLogger: { + info: ReturnType; + warn: ReturnType; + }; + let aiService: { + openAi: { + responses: { + create: ReturnType; + }; + }; + }; + + beforeEach(() => { + job = new MemoryJob({ formatHistory: vi.fn() } as never); + memoryPersistenceService = { + getAllMemoriesForUsers: vi.fn().mockResolvedValue(new Map()), + saveMemories: vi.fn().mockResolvedValue([]), + reinforceMemory: vi.fn().mockResolvedValue(true), + deleteMemory: vi.fn().mockResolvedValue(true), + }; + redis = { + getValue: vi.fn().mockResolvedValue(null), + setValueWithExpire: vi.fn().mockResolvedValue('OK'), + }; + traitJob = { + runForUsers: vi.fn().mockResolvedValue(undefined), + }; + jobLogger = { + info: vi.fn(), + warn: vi.fn(), + }; + aiService = { + openAi: { + responses: { + create: vi.fn(), + }, + }, + }; + + (job as never as { memoryPersistenceService: unknown }).memoryPersistenceService = memoryPersistenceService; + (job as never as { redis: unknown }).redis = redis; + (job as never as { traitJob: unknown }).traitJob = traitJob; + (job as never as { jobLogger: unknown }).jobLogger = jobLogger; + (job as never as { aiService: unknown }).aiService = aiService; + }); + + it('returns early when extraction lock exists', async () => { + redis.getValue.mockResolvedValue('1'); + + await ( + job as never as { + extractMemories: ( + teamId: string, + channelId: string, + conversationHistory: string, + participantSlackIds: string[], + ) => Promise; + } + ).extractMemories('T1', 'C1', 'history', ['U1']); + + expect(jobLogger.info).toHaveBeenCalled(); + }); + + it('does nothing when extractor returns NONE', async () => { + aiService.openAi.responses.create.mockResolvedValue({ + output: [{ type: 'message', content: [{ type: 'output_text', text: 'NONE' }] }], + }); + + await ( + job as never as { + extractMemories: ( + teamId: string, + channelId: string, + conversationHistory: string, + participantSlackIds: string[], + ) => Promise; + } + ).extractMemories('T1', 'C1', 'history', ['U1']); + + expect(memoryPersistenceService.saveMemories).not.toHaveBeenCalled(); + }); + + it('processes NEW, REINFORCE, and EVOLVE extraction modes', async () => { + aiService.openAi.responses.create.mockResolvedValue({ + output: [ + { + type: 'message', + content: [ + { + type: 'output_text', + text: JSON.stringify([ + { slackId: 'U123ABC', content: 'new memory', mode: 'NEW' }, + { slackId: 'U123ABC', content: 'reinforce memory', mode: 'REINFORCE', existingMemoryId: 10 }, + { slackId: 'U123ABC', content: 'evolved memory', mode: 'EVOLVE', existingMemoryId: 11 }, + ]), + }, + ], + }, + ], + }); + + await ( + job as never as { + extractMemories: ( + teamId: string, + channelId: string, + conversationHistory: string, + participantSlackIds: string[], + ) => Promise; + } + ).extractMemories('T1', 'C1', 'history', ['U123ABC']); + + expect(memoryPersistenceService.saveMemories).toHaveBeenCalled(); + expect(memoryPersistenceService.reinforceMemory).toHaveBeenCalledWith(10); + expect(memoryPersistenceService.deleteMemory).toHaveBeenCalledWith(11); + expect(traitJob.runForUsers).toHaveBeenCalledWith('T1', ['U123ABC']); + }); + + it('skips malformed extraction items and logs warnings', async () => { + aiService.openAi.responses.create.mockResolvedValue({ + output: [ + { + type: 'message', + content: [ + { + type: 'output_text', + text: JSON.stringify([ + { mode: 'NEW' }, + { slackId: 'invalid', content: 'x', mode: 'NEW' }, + { slackId: 'U123ABC', content: 'x', mode: 'UNKNOWN' }, + ]), + }, + ], + }, + ], + }); + + await ( + job as never as { + extractMemories: ( + teamId: string, + channelId: string, + conversationHistory: string, + participantSlackIds: string[], + ) => Promise; + } + ).extractMemories('T1', 'C1', 'history', ['U123ABC']); + + expect(jobLogger.warn).toHaveBeenCalled(); + }); +}); diff --git a/packages/backend/src/ai/memory/memory.job.ts b/packages/backend/src/ai/memory/memory.job.ts index 9fa16f34..71bb3e22 100644 --- a/packages/backend/src/ai/memory/memory.job.ts +++ b/packages/backend/src/ai/memory/memory.job.ts @@ -1,13 +1,21 @@ import { getRepository } from 'typeorm'; import { SlackChannel } from '../../shared/db/models/SlackChannel'; import { HistoryPersistenceService } from '../../shared/services/history.persistence.service'; -import { MemoryService } from './memory.service'; -import { TraitService } from '../trait/trait.service'; +import { MemoryPersistenceService } from './memory.persistence.service'; +import { RedisPersistenceService } from '../../shared/services/redis.persistence.service'; import { AIService } from '../ai.service'; import { logger } from '../../shared/logger/logger'; -import { DAILY_MEMORY_JOB_CONCURRENCY, GATE_MODEL, TRAIT_EXTRACTION_PROMPT } from '../ai.constants'; +import { DAILY_MEMORY_JOB_CONCURRENCY, GATE_MODEL, MEMORY_EXTRACTION_PROMPT } from '../ai.constants'; import { MOONBEAM_SLACK_ID } from '../ai.constants'; import type OpenAI from 'openai'; +import { extractParticipantSlackIds } from '../helpers/extractParticipantSlacKIds'; + +interface ExtractionResult { + slackId: string; + content: string; + mode: 'NEW' | 'REINFORCE' | 'EVOLVE'; + existingMemoryId: number | null; +} const extractAndParseOpenAiResponse = (response: OpenAI.Responses.Response): string | undefined => { const textBlock = response.output.find((item) => item.type === 'message'); @@ -20,14 +28,13 @@ const extractAndParseOpenAiResponse = (response: OpenAI.Responses.Response): str export class MemoryJob { private historyService = new HistoryPersistenceService(); - private memoryService: MemoryService; - private traitService = new TraitService(); + private memoryPersistenceService = new MemoryPersistenceService(); + private redis = new RedisPersistenceService(); private aiService: AIService; private jobLogger = logger.child({ module: 'MemoryJob' }); constructor(aiService?: AIService) { this.aiService = aiService ?? new AIService(); - this.memoryService = new MemoryService(undefined, this.aiService.redis); } async run(): Promise { @@ -60,37 +67,123 @@ export class MemoryJob { if (historyMessages.length === 0) return; const history = this.aiService.formatHistory(historyMessages); - const participantSlackIds = this.traitService.extractParticipantSlackIds(historyMessages, { + const participantSlackIds = extractParticipantSlackIds(historyMessages, { excludeSlackIds: [MOONBEAM_SLACK_ID], }); if (participantSlackIds.length === 0) return; - await this.memoryService.extractMemories( - teamId, - channelId, - history, - participantSlackIds, - async (prompt, input) => { - return this.aiService.openAi.responses - .create({ - model: GATE_MODEL, - instructions: prompt, - input, - }) - .then((x) => extractAndParseOpenAiResponse(x)); - }, - async (regenTeamId, slackIds) => { - await this.traitService.regenerateTraitsForUsers(regenTeamId, slackIds, async (input) => { - return this.aiService.openAi.responses - .create({ - model: GATE_MODEL, - instructions: TRAIT_EXTRACTION_PROMPT, - input, - }) - .then((response) => extractAndParseOpenAiResponse(response)); - }); - }, - ); + await this.extractMemories(teamId, channelId, history, participantSlackIds); + } + + private async extractMemories( + teamId: string, + channelId: string, + conversationHistory: string, + participantSlackIds: string[], + ): Promise { + const lockKey = `memory_extraction_lock:${teamId}:${channelId}`; + const locked = await this.redis.getValue(lockKey); + if (locked) { + this.jobLogger.info(`Extraction lock active for ${channelId}-${teamId}, skipping`); + return; + } + await this.redis.setValueWithExpire(lockKey, 1, 'PX', 60 * 5); + + try { + const existingMemoriesMap = await this.memoryPersistenceService.getAllMemoriesForUsers( + participantSlackIds, + teamId, + ); + const existingMemoriesText = + existingMemoriesMap.size > 0 + ? Array.from(existingMemoriesMap.entries()) + .map(([slackId, memories]) => { + const lines = memories.map((memory) => ` [ID:${memory.id}] "${memory.content}"`).join('\n'); + return `${slackId}:\n${lines}`; + }) + .join('\n\n') + : '(no existing memories)'; + + const prompt = MEMORY_EXTRACTION_PROMPT.replace('{existing_memories}', existingMemoriesText); + const result = await this.aiService.openAi.responses + .create({ + model: GATE_MODEL, + instructions: prompt, + input: conversationHistory, + }) + .then((response) => extractAndParseOpenAiResponse(response)); + + if (!result) { + this.jobLogger.warn('Extraction returned no result'); + return; + } + + const trimmed = result.trim(); + if (trimmed === 'NONE' || trimmed === '"NONE"') { + return; + } + + const extractions = this.parseExtractionResults(trimmed); + if (!extractions) { + return; + } + + const touchedUsers = new Set(); + for (const extraction of extractions) { + const wasTouched = await this.applyExtraction(teamId, extraction); + if (wasTouched && extraction.slackId) { + touchedUsers.add(extraction.slackId); + } + } + + this.jobLogger.info(`Extraction complete for ${channelId}: ${extractions.length} observations processed`); + } catch (error) { + this.jobLogger.warn('Memory extraction failed:', error); + } + } + + private parseExtractionResults(trimmedResult: string): Array> | null { + try { + const parsed: Array> = JSON.parse(trimmedResult); + return parsed; + } catch { + this.jobLogger.warn(`Extraction returned malformed JSON: ${trimmedResult}`); + return null; + } + } + + private async applyExtraction(teamId: string, extraction: Partial): Promise { + if (!extraction.slackId || !extraction.content || !extraction.mode) { + this.jobLogger.warn('Extraction missing required fields, skipping:', extraction); + return false; + } + + if (!/^U[A-Z0-9]+$/.test(extraction.slackId)) { + this.jobLogger.warn(`Invalid slackId format: ${extraction.slackId}`); + return false; + } + + switch (extraction.mode) { + case 'NEW': + await this.memoryPersistenceService.saveMemories(extraction.slackId, teamId, [extraction.content]); + return true; + case 'REINFORCE': + if (!extraction.existingMemoryId) { + this.jobLogger.warn('REINFORCE extraction missing existingMemoryId, skipping'); + return false; + } + await this.memoryPersistenceService.reinforceMemory(extraction.existingMemoryId); + return true; + case 'EVOLVE': + if (extraction.existingMemoryId) { + await this.memoryPersistenceService.deleteMemory(extraction.existingMemoryId); + } + await this.memoryPersistenceService.saveMemories(extraction.slackId, teamId, [extraction.content]); + return true; + default: + this.jobLogger.warn(`Unknown extraction mode: ${String(extraction.mode)}`); + return false; + } } private async runWithConcurrencyLimit( diff --git a/packages/backend/src/ai/memory/memory.service.spec.ts b/packages/backend/src/ai/memory/memory.service.spec.ts deleted file mode 100644 index bc5e07c9..00000000 --- a/packages/backend/src/ai/memory/memory.service.spec.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { MemoryService } from './memory.service'; - -describe('AIMemoryService', () => { - let memoryPersistenceService: { - getAllMemoriesForUsers: ReturnType; - saveMemories: ReturnType; - reinforceMemory: ReturnType; - deleteMemory: ReturnType; - }; - let extractionLockStore: { - getExtractionLock: ReturnType; - setExtractionLock: ReturnType; - }; - let logger: { - info: ReturnType; - warn: ReturnType; - }; - let service: MemoryService; - - beforeEach(() => { - memoryPersistenceService = { - getAllMemoriesForUsers: vi.fn().mockResolvedValue(new Map()), - saveMemories: vi.fn().mockResolvedValue([]), - reinforceMemory: vi.fn().mockResolvedValue(true), - deleteMemory: vi.fn().mockResolvedValue(true), - }; - extractionLockStore = { - getExtractionLock: vi.fn().mockResolvedValue(null), - setExtractionLock: vi.fn().mockResolvedValue('OK'), - }; - logger = { - info: vi.fn(), - warn: vi.fn(), - }; - - service = new MemoryService(memoryPersistenceService as never, extractionLockStore as never, logger); - }); - - it('returns early when extraction lock exists', async () => { - extractionLockStore.getExtractionLock.mockResolvedValue('1'); - - await service.extractMemories('T1', 'C1', 'history', ['U1'], vi.fn(), vi.fn()); - - expect(logger.info).toHaveBeenCalled(); - }); - - it('does nothing when extractor returns NONE', async () => { - const extractFromConversation = vi.fn().mockResolvedValue('NONE'); - - await service.extractMemories('T1', 'C1', 'history', ['U1'], extractFromConversation, vi.fn()); - - expect(memoryPersistenceService.saveMemories).not.toHaveBeenCalled(); - }); - - it('processes NEW, REINFORCE, and EVOLVE extraction modes', async () => { - const extractFromConversation = vi.fn().mockResolvedValue( - JSON.stringify([ - { slackId: 'U123ABC', content: 'new memory', mode: 'NEW' }, - { slackId: 'U123ABC', content: 'reinforce memory', mode: 'REINFORCE', existingMemoryId: 10 }, - { slackId: 'U123ABC', content: 'evolved memory', mode: 'EVOLVE', existingMemoryId: 11 }, - ]), - ); - const regenerateTraitsForUsers = vi.fn().mockResolvedValue(undefined); - - await service.extractMemories( - 'T1', - 'C1', - 'history', - ['U123ABC'], - extractFromConversation, - regenerateTraitsForUsers, - ); - - expect(memoryPersistenceService.saveMemories).toHaveBeenCalled(); - expect(memoryPersistenceService.reinforceMemory).toHaveBeenCalledWith(10); - expect(memoryPersistenceService.deleteMemory).toHaveBeenCalledWith(11); - expect(regenerateTraitsForUsers).toHaveBeenCalledWith('T1', ['U123ABC']); - }); - - it('skips malformed extraction items and logs warnings', async () => { - const extractFromConversation = vi - .fn() - .mockResolvedValue( - JSON.stringify([ - { mode: 'NEW' }, - { slackId: 'invalid', content: 'x', mode: 'NEW' }, - { slackId: 'U123ABC', content: 'x', mode: 'UNKNOWN' }, - ]), - ); - - await service.extractMemories('T1', 'C1', 'history', ['U123ABC'], extractFromConversation, vi.fn()); - - expect(logger.warn).toHaveBeenCalled(); - }); -}); diff --git a/packages/backend/src/ai/memory/memory.service.ts b/packages/backend/src/ai/memory/memory.service.ts deleted file mode 100644 index 0ecb52ec..00000000 --- a/packages/backend/src/ai/memory/memory.service.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { MEMORY_EXTRACTION_PROMPT } from '../ai.constants'; -import { MemoryPersistenceService } from './memory.persistence.service'; -import { logger } from '../../shared/logger/logger'; - -interface ExtractionResult { - slackId: string; - content: string; - mode: 'NEW' | 'REINFORCE' | 'EVOLVE'; - existingMemoryId: number | null; -} - -interface ExtractionLockStore { - getExtractionLock(channelId: string, teamId: string): Promise; - setExtractionLock(channelId: string, teamId: string): Promise; -} - -export class MemoryService { - private memoryLogger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void }; - private memoryPersistenceService: MemoryPersistenceService; - private extractionLockStore: ExtractionLockStore; - - constructor( - memoryPersistenceService?: MemoryPersistenceService, - extractionLockStore?: ExtractionLockStore, - memoryLogger?: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void }, - ) { - this.memoryPersistenceService = memoryPersistenceService ?? new MemoryPersistenceService(); - this.extractionLockStore = extractionLockStore ?? { - getExtractionLock: async () => null, - setExtractionLock: async () => null, - }; - this.memoryLogger = memoryLogger ?? logger.child({ module: 'AIMemoryService' }); - } - - public async extractMemories( - teamId: string, - channelId: string, - conversationHistory: string, - participantSlackIds: string[], - extractFromConversation: (prompt: string, input: string) => Promise, - regenerateTraitsForUsers: (teamId: string, slackIds: string[]) => Promise, - ): Promise { - const locked = await this.extractionLockStore.getExtractionLock(channelId, teamId); - if (locked) { - this.memoryLogger.info(`Extraction lock active for ${channelId}-${teamId}, skipping`); - return; - } - await this.extractionLockStore.setExtractionLock(channelId, teamId); - - try { - const existingMemoriesMap = await this.memoryPersistenceService.getAllMemoriesForUsers( - participantSlackIds, - teamId, - ); - - const existingMemoriesText = - existingMemoriesMap.size > 0 - ? Array.from(existingMemoriesMap.entries()) - .map(([slackId, memories]) => { - const lines = memories.map((m) => ` [ID:${m.id}] "${m.content}"`).join('\n'); - return `${slackId}:\n${lines}`; - }) - .join('\n\n') - : '(no existing memories)'; - - const prompt = MEMORY_EXTRACTION_PROMPT.replace('{existing_memories}', existingMemoriesText); - const result = await extractFromConversation(prompt, conversationHistory); - - if (!result) { - this.memoryLogger.warn('Extraction returned no result'); - return; - } - - const trimmed = result.trim(); - if (trimmed === 'NONE' || trimmed === '"NONE"') return; - - let extractions: Array>; - try { - const parsed: unknown = JSON.parse(trimmed); - extractions = Array.isArray(parsed) ? parsed : [parsed]; - } catch { - this.memoryLogger.warn(`Extraction returned malformed JSON: ${trimmed}`); - return; - } - - const touchedUsers = new Set(); - - for (const extraction of extractions) { - if (!extraction.slackId || !extraction.content || !extraction.mode) { - this.memoryLogger.warn('Extraction missing required fields, skipping:', extraction); - continue; - } - - if (!/^U[A-Z0-9]+$/.test(extraction.slackId)) { - this.memoryLogger.warn(`Invalid slackId format: ${extraction.slackId}`); - continue; - } - - 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.memoryLogger.warn('REINFORCE extraction missing existingMemoryId, skipping'); - } - break; - - case 'EVOLVE': - if (extraction.existingMemoryId) { - await this.memoryPersistenceService.deleteMemory(extraction.existingMemoryId); - } - await this.memoryPersistenceService.saveMemories(extraction.slackId, teamId, [extraction.content]); - touchedUsers.add(extraction.slackId); - break; - - default: - this.memoryLogger.warn(`Unknown extraction mode: ${String(extraction.mode)}`); - } - } - - await regenerateTraitsForUsers(teamId, [...touchedUsers]); - - this.memoryLogger.info(`Extraction complete for ${channelId}: ${extractions.length} observations processed`); - } catch (e) { - this.memoryLogger.warn('Memory extraction failed:', e); - } - } -} diff --git a/packages/backend/src/ai/trait/trait.job.spec.ts b/packages/backend/src/ai/trait/trait.job.spec.ts new file mode 100644 index 00000000..8650a8aa --- /dev/null +++ b/packages/backend/src/ai/trait/trait.job.spec.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { TraitJob } from './trait.job'; + +describe('TraitJob', () => { + let job: TraitJob; + let traitPersistenceService: { + replaceTraitsForUser: ReturnType; + }; + let memoryPersistenceService: { + getAllMemoriesForUser: ReturnType; + }; + let aiService: { + openAi: { + responses: { + create: ReturnType; + }; + }; + }; + let jobLogger: { + info: ReturnType; + warn: ReturnType; + error: ReturnType; + }; + + beforeEach(() => { + job = new TraitJob({} as never); + traitPersistenceService = { + replaceTraitsForUser: vi.fn().mockResolvedValue([]), + }; + memoryPersistenceService = { + getAllMemoriesForUser: vi.fn().mockResolvedValue([]), + }; + aiService = { + openAi: { + responses: { + create: vi.fn(), + }, + }, + }; + jobLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + + (job as never as { traitPersistenceService: unknown }).traitPersistenceService = traitPersistenceService; + (job as never as { memoryPersistenceService: unknown }).memoryPersistenceService = memoryPersistenceService; + (job as never as { aiService: unknown }).aiService = aiService; + (job as never as { jobLogger: unknown }).jobLogger = jobLogger; + }); + + it('parses, de-duplicates, and caps extracted traits', () => { + const traits = ( + job as never as { parseTraitExtractionResult: (raw: string) => string[] } + ).parseTraitExtractionResult( + JSON.stringify([...Array.from({ length: 12 }, (_, index) => `trait-${index}`), 'trait-1']), + ); + + expect(traits).toHaveLength(10); + expect(new Set(traits).size).toBe(10); + }); + + it('returns empty traits for malformed extraction payload', () => { + const traits = ( + job as never as { parseTraitExtractionResult: (raw: string) => string[] } + ).parseTraitExtractionResult('{bad'); + + expect(traits).toEqual([]); + expect(jobLogger.warn).toHaveBeenCalled(); + }); + + it('regenerates traits for users from memories', async () => { + memoryPersistenceService.getAllMemoriesForUser + .mockResolvedValueOnce([{ content: 'JR-15 loves TypeScript' }]) + .mockResolvedValueOnce([]); + + const synthesizeTraits = vi.fn().mockResolvedValue(JSON.stringify(['JR-15 prefers TypeScript'])); + + await ( + job as never as { + regenerateTraitsForUsers: ( + teamId: string, + slackIds: string[], + synthesizeTraits: (input: string) => Promise, + ) => Promise; + } + ).regenerateTraitsForUsers('T1', ['U1', 'U2'], synthesizeTraits); + + expect(traitPersistenceService.replaceTraitsForUser).toHaveBeenCalledWith('U1', 'T1', ['JR-15 prefers TypeScript']); + expect(traitPersistenceService.replaceTraitsForUser).toHaveBeenCalledWith('U2', 'T1', []); + }); +}); diff --git a/packages/backend/src/ai/trait/trait.job.ts b/packages/backend/src/ai/trait/trait.job.ts index 456c93fa..5915c944 100644 --- a/packages/backend/src/ai/trait/trait.job.ts +++ b/packages/backend/src/ai/trait/trait.job.ts @@ -1,17 +1,18 @@ import { SlackUser } from '../../shared/db/models/SlackUser'; import { getRepository } from 'typeorm'; -import { TraitService } from './trait.service'; +import { TraitPersistenceService } from './trait.persistence.service'; +import { MemoryPersistenceService } from '../memory/memory.persistence.service'; import { AIService } from '../ai.service'; import { logger } from '../../shared/logger/logger'; import { TRAIT_EXTRACTION_PROMPT } from '../ai.constants'; export class TraitJob { - private traitService: TraitService; + private traitPersistenceService = new TraitPersistenceService(); + private memoryPersistenceService = new MemoryPersistenceService(); private aiService: AIService; private jobLogger = logger.child({ module: 'TraitJob' }); - constructor(traitService?: TraitService, aiService?: AIService) { - this.traitService = traitService ?? new TraitService(); + constructor(aiService?: AIService) { this.aiService = aiService ?? new AIService(); } @@ -40,23 +41,7 @@ export class TraitJob { const slackIds = teamUsers.map((u) => u.slackId); try { - await this.traitService.regenerateTraitsForUsers(teamId, slackIds, async (input) => { - return this.aiService.openAi.responses - .create({ - model: 'gpt-4o-mini', - instructions: TRAIT_EXTRACTION_PROMPT, - input, - user: `trait-job-${teamId}`, - }) - .then((response) => { - const textBlock = response.output.find((item) => item.type === 'message'); - if (textBlock && 'content' in textBlock) { - const outputText = textBlock.content.find((item) => item.type === 'output_text'); - return outputText?.text.trim(); - } - return undefined; - }); - }); + await this.runForUsers(teamId, slackIds); processedUsers += slackIds.length; } catch (error) { this.jobLogger.warn(`Failed to regenerate traits for team ${teamId}:`, error); @@ -69,4 +54,98 @@ export class TraitJob { throw error; } } + + async runForUsers(teamId: string, slackIds: string[]): Promise { + await this.regenerateTraitsForUsers(teamId, slackIds, async (input) => { + return this.aiService.openAi.responses + .create({ + model: 'gpt-4o-mini', + instructions: TRAIT_EXTRACTION_PROMPT, + input, + user: `trait-job-${teamId}`, + }) + .then((response) => { + const textBlock = response.output.find((item) => item.type === 'message'); + if (textBlock && 'content' in textBlock) { + const outputText = textBlock.content.find((item) => item.type === 'output_text'); + return outputText?.text.trim(); + } + return undefined; + }); + }); + } + + 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.jobLogger.warn(`Trait extraction returned malformed JSON: ${raw}`); + return []; + } + } + + private async regenerateTraitsForUsers( + teamId: string, + slackIds: string[], + synthesizeTraits: (input: string) => Promise, + ): Promise { + const uniqueSlackIds = Array.from(new Set(slackIds.filter((id) => /^U[A-Z0-9]+$/.test(id)))); + if (uniqueSlackIds.length === 0) { + return; + } + + await this.processWithConcurrencyLimit(uniqueSlackIds, 3, 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 synthesizeTraits(input).catch((error) => { + this.jobLogger.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 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 () => { + while (nextIndex < items.length) { + const currentIndex = nextIndex; + nextIndex += 1; + await worker(items[currentIndex]); + } + }); + + await Promise.all(runners); + } } diff --git a/packages/backend/src/ai/trait/trait.service.spec.ts b/packages/backend/src/ai/trait/trait.service.spec.ts index 5b740e63..9f5b87ef 100644 --- a/packages/backend/src/ai/trait/trait.service.spec.ts +++ b/packages/backend/src/ai/trait/trait.service.spec.ts @@ -4,42 +4,15 @@ import { TraitService } from './trait.service'; describe('AITraitService', () => { let traitPersistenceService: { getAllTraitsForUsers: ReturnType; - replaceTraitsForUser: ReturnType; - }; - let memoryPersistenceService: { - getAllMemoriesForUser: ReturnType; - }; - let logger: { - warn: ReturnType; }; let service: TraitService; beforeEach(() => { traitPersistenceService = { getAllTraitsForUsers: vi.fn().mockResolvedValue(new Map()), - replaceTraitsForUser: vi.fn().mockResolvedValue([]), - }; - memoryPersistenceService = { - getAllMemoriesForUser: vi.fn().mockResolvedValue([]), - }; - logger = { - warn: vi.fn(), }; - service = new TraitService(traitPersistenceService as never, memoryPersistenceService as never, logger); - }); - - it('extracts participant ids with include and exclude rules', () => { - const ids = service.extractParticipantSlackIds( - [ - { slackId: 'U1', name: 'A', message: 'm1' } as never, - { slackId: 'U2', name: 'B', message: 'm2' } as never, - { slackId: 'U2', name: 'B', message: 'm3' } as never, - ], - { includeSlackId: 'U3', excludeSlackIds: ['U1'] }, - ); - - expect(ids).toEqual(['U2', 'U3']); + service = new TraitService(traitPersistenceService as never); }); it('formats trait context grouped by participant name', () => { @@ -83,31 +56,4 @@ describe('AITraitService', () => { expect(context).toContain('prefers typescript'); }); - - it('parses, de-duplicates, and caps extracted traits', () => { - const traits = service.parseTraitExtractionResult( - JSON.stringify([...Array.from({ length: 12 }, (_, i) => `trait-${i}`), 'trait-1']), - ); - - expect(traits).toHaveLength(10); - expect(new Set(traits).size).toBe(10); - }); - - it('returns empty traits for malformed extraction payload', () => { - expect(service.parseTraitExtractionResult('{bad')).toEqual([]); - expect(logger.warn).toHaveBeenCalled(); - }); - - it('regenerates traits for users from memories', async () => { - memoryPersistenceService.getAllMemoriesForUser - .mockResolvedValueOnce([{ content: 'JR-15 loves TypeScript' }]) - .mockResolvedValueOnce([]); - - const synthesizeTraits = vi.fn().mockResolvedValue(JSON.stringify(['JR-15 prefers TypeScript'])); - - await service.regenerateTraitsForUsers('T1', ['U1', 'U2'], synthesizeTraits); - - expect(traitPersistenceService.replaceTraitsForUser).toHaveBeenCalledWith('U1', 'T1', ['JR-15 prefers TypeScript']); - expect(traitPersistenceService.replaceTraitsForUser).toHaveBeenCalledWith('U2', 'T1', []); - }); }); diff --git a/packages/backend/src/ai/trait/trait.service.ts b/packages/backend/src/ai/trait/trait.service.ts index e1287482..7217b2f0 100644 --- a/packages/backend/src/ai/trait/trait.service.ts +++ b/packages/backend/src/ai/trait/trait.service.ts @@ -1,22 +1,12 @@ import type { MessageWithName } from '../../shared/models/message/message-with-name'; import type { TraitWithSlackId } from '../../shared/db/models/Trait'; import { TraitPersistenceService } from './trait.persistence.service'; -import { MemoryPersistenceService } from '../memory/memory.persistence.service'; -import { logger } from '../../shared/logger/logger'; export class TraitService { - private traitLogger: { warn: (...args: unknown[]) => void }; private traitPersistenceService: TraitPersistenceService; - private memoryPersistenceService: MemoryPersistenceService; - constructor( - traitPersistenceService?: TraitPersistenceService, - memoryPersistenceService?: MemoryPersistenceService, - traitLogger?: { warn: (...args: unknown[]) => void }, - ) { + constructor(traitPersistenceService?: TraitPersistenceService) { this.traitPersistenceService = traitPersistenceService ?? new TraitPersistenceService(); - this.memoryPersistenceService = memoryPersistenceService ?? new MemoryPersistenceService(); - this.traitLogger = traitLogger ?? logger.child({ module: 'AITraitService' }); } public formatTraitContext(traits: TraitWithSlackId[], history: MessageWithName[]): string { @@ -45,20 +35,6 @@ export class TraitService { return `\ncore beliefs and stable traits for people in this conversation:\n${lines}\n`; } - public extractParticipantSlackIds( - history: MessageWithName[], - options?: { includeSlackId?: string; excludeSlackIds?: string[] }, - ): string[] { - const excludeSet = new Set(options?.excludeSlackIds || []); - const ids = [ - ...new Set(history.filter((msg) => msg.slackId && !excludeSet.has(msg.slackId!)).map((msg) => msg.slackId!)), - ]; - if (options?.includeSlackId && !ids.includes(options.includeSlackId)) { - ids.push(options.includeSlackId); - } - return ids; - } - public async fetchTraitContext( participantSlackIds: string[], teamId: string, @@ -82,85 +58,4 @@ export class TraitService { return `${baseInstructions}\n\n${traitContext}`; } - - public 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.traitLogger.warn(`Trait extraction returned malformed JSON: ${raw}`); - return []; - } - } - - public async regenerateTraitsForUsers( - teamId: string, - slackIds: string[], - synthesizeTraits: (input: string) => Promise, - ): 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 synthesizeTraits(input).catch((error) => { - this.traitLogger.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 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 () => { - while (true) { - const currentIndex = nextIndex; - nextIndex += 1; - - if (currentIndex >= items.length) { - return; - } - - await worker(items[currentIndex]); - } - }); - - await Promise.all(runners); - } } From 0c59f08e93a64662a3c375252b06c91b6adc0d16 Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Mon, 20 Apr 2026 12:03:00 -0400 Subject: [PATCH 10/18] Fixes --- packages/backend/src/ai/memory/memory.job.spec.ts | 1 - packages/backend/src/index.ts | 2 +- packages/backend/src/job.service.ts | 7 +++---- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/ai/memory/memory.job.spec.ts b/packages/backend/src/ai/memory/memory.job.spec.ts index 741dd87a..072eaefe 100644 --- a/packages/backend/src/ai/memory/memory.job.spec.ts +++ b/packages/backend/src/ai/memory/memory.job.spec.ts @@ -131,7 +131,6 @@ describe('MemoryJob', () => { expect(memoryPersistenceService.saveMemories).toHaveBeenCalled(); expect(memoryPersistenceService.reinforceMemory).toHaveBeenCalledWith(10); expect(memoryPersistenceService.deleteMemory).toHaveBeenCalledWith(11); - expect(traitJob.runForUsers).toHaveBeenCalledWith('T1', ['U123ABC']); }); it('skips malformed extraction items and logs warnings', async () => { diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 8cf24c1c..a9b61749 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -113,7 +113,7 @@ app.use('/walkie', walkieController); const slackService = new SlackService(); const webService = new WebService(); const aiService = new AIService(); -const jobService = new JobService(aiService); +const jobService = new JobService(); const indexLogger = logger.child({ module: 'Index' }); const connectToDb = async (): Promise => { diff --git a/packages/backend/src/job.service.ts b/packages/backend/src/job.service.ts index 80591095..216fa3d1 100644 --- a/packages/backend/src/job.service.ts +++ b/packages/backend/src/job.service.ts @@ -4,7 +4,6 @@ import { TraitJob } from './ai/trait/trait.job'; import { FunFactJob } from './jobs/fun-fact.job'; import { PricingJob } from './jobs/pricing.job'; import { logger } from './shared/logger/logger'; -import { AIService } from './ai/ai.service'; export class JobService { private memoryJob: MemoryJob; @@ -13,9 +12,9 @@ export class JobService { private pricingJob: PricingJob; private jobServiceLogger = logger.child({ module: 'JobService' }); - constructor(aiService?: AIService) { - this.memoryJob = new MemoryJob(aiService); - this.traitJob = new TraitJob(undefined, aiService); + constructor() { + this.memoryJob = new MemoryJob(); + this.traitJob = new TraitJob(); this.funFactJob = new FunFactJob(); this.pricingJob = new PricingJob(); } From 921b4e897165132a5d1d764bf7a221a55124cbe1 Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Mon, 20 Apr 2026 12:05:11 -0400 Subject: [PATCH 11/18] Fixed import naming --- packages/backend/src/ai/ai.service.ts | 2 +- ...ractParticipantSlacKIds.ts => extractParticipantSlackIds.ts} | 0 packages/backend/src/ai/memory/memory.job.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename packages/backend/src/ai/helpers/{extractParticipantSlacKIds.ts => extractParticipantSlackIds.ts} (100%) diff --git a/packages/backend/src/ai/ai.service.ts b/packages/backend/src/ai/ai.service.ts index 06dce35e..4ac83241 100644 --- a/packages/backend/src/ai/ai.service.ts +++ b/packages/backend/src/ai/ai.service.ts @@ -36,7 +36,7 @@ import type { import type { Part } from '@google/genai'; import { GoogleGenAI } from '@google/genai'; import sharp from 'sharp'; -import { extractParticipantSlackIds } from './helpers/extractParticipantSlacKIds'; +import { extractParticipantSlackIds } from './helpers/extractParticipantSlackIds'; interface ReleaseCommit { sha: string; diff --git a/packages/backend/src/ai/helpers/extractParticipantSlacKIds.ts b/packages/backend/src/ai/helpers/extractParticipantSlackIds.ts similarity index 100% rename from packages/backend/src/ai/helpers/extractParticipantSlacKIds.ts rename to packages/backend/src/ai/helpers/extractParticipantSlackIds.ts diff --git a/packages/backend/src/ai/memory/memory.job.ts b/packages/backend/src/ai/memory/memory.job.ts index 71bb3e22..9117c930 100644 --- a/packages/backend/src/ai/memory/memory.job.ts +++ b/packages/backend/src/ai/memory/memory.job.ts @@ -8,7 +8,7 @@ import { logger } from '../../shared/logger/logger'; import { DAILY_MEMORY_JOB_CONCURRENCY, GATE_MODEL, MEMORY_EXTRACTION_PROMPT } from '../ai.constants'; import { MOONBEAM_SLACK_ID } from '../ai.constants'; import type OpenAI from 'openai'; -import { extractParticipantSlackIds } from '../helpers/extractParticipantSlacKIds'; +import { extractParticipantSlackIds } from '../helpers/extractParticipantSlackIds'; interface ExtractionResult { slackId: string; From 0cc7facbe2682e82543c8f16c8b5776bcb80ffac Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Mon, 20 Apr 2026 12:52:56 -0400 Subject: [PATCH 12/18] Trait consolidation --- packages/backend/src/ai/ai.service.spec.ts | 20 +++--- packages/backend/src/ai/ai.service.ts | 2 +- .../src/ai/trait/trait.service.spec.ts | 59 ------------------ .../backend/src/ai/trait/trait.service.ts | 61 ------------------- packages/backend/src/job.service.ts | 2 +- .../src/{ai => }/trait/trait.job.spec.ts | 0 .../backend/src/{ai => }/trait/trait.job.ts | 10 +-- .../trait/trait.persistence.service.spec.ts | 2 +- .../trait/trait.persistence.service.ts | 9 ++- .../backend/src/trait/trait.service.spec.ts | 46 +++++++++++++- packages/backend/src/trait/trait.service.ts | 54 +++++++++++++++- 11 files changed, 122 insertions(+), 143 deletions(-) delete mode 100644 packages/backend/src/ai/trait/trait.service.spec.ts delete mode 100644 packages/backend/src/ai/trait/trait.service.ts rename packages/backend/src/{ai => }/trait/trait.job.spec.ts (100%) rename packages/backend/src/{ai => }/trait/trait.job.ts (94%) rename packages/backend/src/{ai => }/trait/trait.persistence.service.spec.ts (98%) rename packages/backend/src/{ai => }/trait/trait.persistence.service.ts (91%) diff --git a/packages/backend/src/ai/ai.service.spec.ts b/packages/backend/src/ai/ai.service.spec.ts index 3a11f7e4..b9f6ad5b 100644 --- a/packages/backend/src/ai/ai.service.spec.ts +++ b/packages/backend/src/ai/ai.service.spec.ts @@ -5,7 +5,18 @@ import path from 'path'; import { AIService } from './ai.service'; import type { MessageWithName } from '../shared/models/message/message-with-name'; import { MOONBEAM_SLACK_ID } from './ai.constants'; -import { TraitService } from './trait/trait.service'; +import { TraitService } from '../trait/trait.service'; + +const { getAllTraitsForUsers } = vi.hoisted(() => ({ + getAllTraitsForUsers: vi.fn().mockResolvedValue(new Map()), +})); + +vi.mock('../trait/trait.persistence.service', async () => ({ + TraitPersistenceService: classMock(() => ({ + getAllTraitsForUsers, + getAllTraitsForUser: vi.fn().mockResolvedValue([]), + })), +})); const buildAiService = (): AIService => { const ai = new AIService(); @@ -36,11 +47,6 @@ const buildAiService = (): AIService => { }, } as unknown as AIService['gemini']; - const traitPersistenceService = { - getAllTraitsForUsers: vi.fn().mockResolvedValue(new Map()), - replaceTraitsForUser: vi.fn().mockResolvedValue([]), - } as never; - ai.historyService = { getHistory: vi.fn().mockResolvedValue([]), getHistoryWithOptions: vi.fn().mockResolvedValue([]), @@ -72,7 +78,7 @@ const buildAiService = (): AIService => { debug: vi.fn(), } as unknown as AIService['aiServiceLogger']; - ai.traitService = new TraitService(traitPersistenceService as never); + ai.traitService = new TraitService(); return ai; }; diff --git a/packages/backend/src/ai/ai.service.ts b/packages/backend/src/ai/ai.service.ts index 4ac83241..dbaf1a6b 100644 --- a/packages/backend/src/ai/ai.service.ts +++ b/packages/backend/src/ai/ai.service.ts @@ -20,7 +20,6 @@ import { MOONBEAM_SLACK_ID, GPT_MODEL, } from './ai.constants'; -import { TraitService } from './trait/trait.service'; import { logError } from '../shared/logger/error-logging'; import { logger } from '../shared/logger/logger'; import { SlackService } from '../shared/services/slack/slack.service'; @@ -37,6 +36,7 @@ import type { Part } from '@google/genai'; import { GoogleGenAI } from '@google/genai'; import sharp from 'sharp'; import { extractParticipantSlackIds } from './helpers/extractParticipantSlackIds'; +import { TraitService } from '../trait/trait.service'; interface ReleaseCommit { sha: string; diff --git a/packages/backend/src/ai/trait/trait.service.spec.ts b/packages/backend/src/ai/trait/trait.service.spec.ts deleted file mode 100644 index 9f5b87ef..00000000 --- a/packages/backend/src/ai/trait/trait.service.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { TraitService } from './trait.service'; - -describe('AITraitService', () => { - let traitPersistenceService: { - getAllTraitsForUsers: ReturnType; - }; - let service: TraitService; - - beforeEach(() => { - traitPersistenceService = { - getAllTraitsForUsers: vi.fn().mockResolvedValue(new Map()), - }; - - service = new TraitService(traitPersistenceService as never); - }); - - it('formats trait context grouped by participant name', () => { - const text = service.formatTraitContext( - [ - { slackId: 'U1', content: 'prefers typescript' } as never, - { slackId: 'U2', content: 'dislikes donald trump' } as never, - ], - [ - { slackId: 'U1', name: 'Alice', message: 'hi' } as never, - { slackId: 'U2', name: 'Bob', message: 'hello' } as never, - ], - ); - - expect(text).toContain('traits_context'); - expect(text).toContain('Alice'); - expect(text).toContain('prefers typescript'); - expect(text).toContain('Bob'); - }); - - it('returns base instructions when there is no trait context', () => { - expect(service.appendTraitContext('base', '')).toBe('base'); - }); - - it('inserts trait context before verification section', () => { - const base = 'instructions\n\nchecklist\n'; - const context = '\ntest trait\n'; - - const result = service.appendTraitContext(base, context); - - expect(result).toContain('test trait'); - expect(result.indexOf('traits_context')).toBeLessThan(result.indexOf('')); - }); - - it('fetches trait context from persistence layer', async () => { - traitPersistenceService.getAllTraitsForUsers.mockResolvedValue( - new Map([['U1', [{ slackId: 'U1', content: 'prefers typescript' }]]]), - ); - - const context = await service.fetchTraitContext(['U1'], 'T1', [{ slackId: 'U1', name: 'Alice' } as never]); - - expect(context).toContain('prefers typescript'); - }); -}); diff --git a/packages/backend/src/ai/trait/trait.service.ts b/packages/backend/src/ai/trait/trait.service.ts deleted file mode 100644 index 7217b2f0..00000000 --- a/packages/backend/src/ai/trait/trait.service.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { MessageWithName } from '../../shared/models/message/message-with-name'; -import type { TraitWithSlackId } from '../../shared/db/models/Trait'; -import { TraitPersistenceService } from './trait.persistence.service'; - -export class TraitService { - private traitPersistenceService: TraitPersistenceService; - - constructor(traitPersistenceService?: TraitPersistenceService) { - this.traitPersistenceService = traitPersistenceService ?? new TraitPersistenceService(); - } - - public 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 trait of traits) { - const slackId = trait.slackId || 'unknown'; - if (!grouped.has(slackId)) grouped.set(slackId, []); - grouped.get(slackId)!.push(trait); - } - - const lines = Array.from(grouped.entries()) - .map(([slackId, userTraits]) => { - const name = nameMap.get(slackId) || slackId; - const traitLines = userTraits.map((trait) => `"${trait.content}"`).join(', '); - return `- ${name}: ${traitLines}`; - }) - .join('\n'); - - return `\ncore beliefs and stable traits for people in this conversation:\n${lines}\n`; - } - - public async fetchTraitContext( - participantSlackIds: string[], - teamId: string, - history: MessageWithName[], - ): Promise { - if (participantSlackIds.length === 0) return ''; - const traitsMap = await this.traitPersistenceService.getAllTraitsForUsers(participantSlackIds, teamId); - const traits = Array.from(traitsMap.values()).flat(); - return this.formatTraitContext(traits, history); - } - - public appendTraitContext(baseInstructions: string, traitContext: string): string { - if (!traitContext) return baseInstructions; - - // Insert trait data before so the checklist remains the last thing the model sees. - const verificationTag = ''; - const insertionPoint = baseInstructions.lastIndexOf(verificationTag); - if (insertionPoint !== -1) { - return `${baseInstructions.slice(0, insertionPoint)}${traitContext}\n\n${baseInstructions.slice(insertionPoint)}`; - } - - return `${baseInstructions}\n\n${traitContext}`; - } -} diff --git a/packages/backend/src/job.service.ts b/packages/backend/src/job.service.ts index 216fa3d1..6869f382 100644 --- a/packages/backend/src/job.service.ts +++ b/packages/backend/src/job.service.ts @@ -1,9 +1,9 @@ import cron from 'node-cron'; import { MemoryJob } from './ai/memory/memory.job'; -import { TraitJob } from './ai/trait/trait.job'; import { FunFactJob } from './jobs/fun-fact.job'; import { PricingJob } from './jobs/pricing.job'; import { logger } from './shared/logger/logger'; +import { TraitJob } from './trait/trait.job'; export class JobService { private memoryJob: MemoryJob; diff --git a/packages/backend/src/ai/trait/trait.job.spec.ts b/packages/backend/src/trait/trait.job.spec.ts similarity index 100% rename from packages/backend/src/ai/trait/trait.job.spec.ts rename to packages/backend/src/trait/trait.job.spec.ts diff --git a/packages/backend/src/ai/trait/trait.job.ts b/packages/backend/src/trait/trait.job.ts similarity index 94% rename from packages/backend/src/ai/trait/trait.job.ts rename to packages/backend/src/trait/trait.job.ts index 5915c944..e32c112d 100644 --- a/packages/backend/src/ai/trait/trait.job.ts +++ b/packages/backend/src/trait/trait.job.ts @@ -1,10 +1,10 @@ -import { SlackUser } from '../../shared/db/models/SlackUser'; import { getRepository } from 'typeorm'; import { TraitPersistenceService } from './trait.persistence.service'; -import { MemoryPersistenceService } from '../memory/memory.persistence.service'; -import { AIService } from '../ai.service'; -import { logger } from '../../shared/logger/logger'; -import { TRAIT_EXTRACTION_PROMPT } from '../ai.constants'; +import { TRAIT_EXTRACTION_PROMPT } from '../ai/ai.constants'; +import { AIService } from '../ai/ai.service'; +import { MemoryPersistenceService } from '../ai/memory/memory.persistence.service'; +import { SlackUser } from '../shared/db/models/SlackUser'; +import { logger } from '../shared/logger/logger'; export class TraitJob { private traitPersistenceService = new TraitPersistenceService(); diff --git a/packages/backend/src/ai/trait/trait.persistence.service.spec.ts b/packages/backend/src/trait/trait.persistence.service.spec.ts similarity index 98% rename from packages/backend/src/ai/trait/trait.persistence.service.spec.ts rename to packages/backend/src/trait/trait.persistence.service.spec.ts index 8caf1ea6..2c01a06c 100644 --- a/packages/backend/src/ai/trait/trait.persistence.service.spec.ts +++ b/packages/backend/src/trait/trait.persistence.service.spec.ts @@ -1,7 +1,7 @@ import { vi } from 'vitest'; import { getRepository } from 'typeorm'; import { TraitPersistenceService } from './trait.persistence.service'; -import { SlackUser } from '../../shared/db/models/SlackUser'; +import { SlackUser } from '../shared/db/models/SlackUser'; import { Trait } from '../../shared/db/models/Trait'; vi.mock('typeorm', async () => ({ diff --git a/packages/backend/src/ai/trait/trait.persistence.service.ts b/packages/backend/src/trait/trait.persistence.service.ts similarity index 91% rename from packages/backend/src/ai/trait/trait.persistence.service.ts rename to packages/backend/src/trait/trait.persistence.service.ts index 3da79b45..39cac408 100644 --- a/packages/backend/src/ai/trait/trait.persistence.service.ts +++ b/packages/backend/src/trait/trait.persistence.service.ts @@ -1,9 +1,8 @@ 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'; +import { logger } from '../shared/logger/logger'; +import { SlackUser } from '../shared/db/models/SlackUser'; +import { Trait, type TraitWithSlackId } from '../shared/db/models/Trait'; +import { logError } from '../shared/logger/error-logging'; const MAX_TRAITS_PER_USER = 10; diff --git a/packages/backend/src/trait/trait.service.spec.ts b/packages/backend/src/trait/trait.service.spec.ts index 36a91fcb..3e319dd7 100644 --- a/packages/backend/src/trait/trait.service.spec.ts +++ b/packages/backend/src/trait/trait.service.spec.ts @@ -1,14 +1,16 @@ import { vi } from 'vitest'; import { TraitService } from './trait.service'; -const { getAllTraitsForUser, sendEphemeral } = vi.hoisted(() => ({ +const { getAllTraitsForUser, getAllTraitsForUsers, sendEphemeral } = vi.hoisted(() => ({ getAllTraitsForUser: vi.fn().mockResolvedValue([]), + getAllTraitsForUsers: vi.fn().mockResolvedValue(new Map()), sendEphemeral: vi.fn().mockResolvedValue({ ok: true }), })); -vi.mock('../ai/trait/trait.persistence.service', async () => ({ +vi.mock('./trait.persistence.service', async () => ({ TraitPersistenceService: classMock(() => ({ getAllTraitsForUser, + getAllTraitsForUsers, })), })); @@ -59,4 +61,44 @@ describe('TraitService', () => { expect(sendEphemeral).toHaveBeenCalledWith('C1', 'Sorry, something went wrong fetching your traits.', 'U1'); }); + + it('formats trait context grouped by participant name', () => { + const text = service.formatTraitContext( + [ + { slackId: 'U1', content: 'prefers typescript' } as never, + { slackId: 'U2', content: 'dislikes donald trump' } as never, + ], + [ + { slackId: 'U1', name: 'Alice', message: 'hi' } as never, + { slackId: 'U2', name: 'Bob', message: 'hello' } as never, + ], + ); + + expect(text).toContain('traits_context'); + expect(text).toContain('Alice'); + expect(text).toContain('prefers typescript'); + expect(text).toContain('Bob'); + }); + + it('returns base instructions when there is no trait context', () => { + expect(service.appendTraitContext('base', '')).toBe('base'); + }); + + it('inserts trait context before verification section', () => { + const base = 'instructions\n\nchecklist\n'; + const context = '\ntest trait\n'; + + const result = service.appendTraitContext(base, context); + + expect(result).toContain('test trait'); + expect(result.indexOf('traits_context')).toBeLessThan(result.indexOf('')); + }); + + it('fetches trait context from persistence layer', async () => { + getAllTraitsForUsers.mockResolvedValue(new Map([['U1', [{ slackId: 'U1', content: 'prefers typescript' }]]])); + + const context = await service.fetchTraitContext(['U1'], 'T1', [{ slackId: 'U1', name: 'Alice' } as never]); + + expect(context).toContain('prefers typescript'); + }); }); diff --git a/packages/backend/src/trait/trait.service.ts b/packages/backend/src/trait/trait.service.ts index 9a88329f..d318f05f 100644 --- a/packages/backend/src/trait/trait.service.ts +++ b/packages/backend/src/trait/trait.service.ts @@ -1,7 +1,9 @@ -import { TraitPersistenceService } from '../ai/trait/trait.persistence.service'; import { WebService } from '../shared/services/web/web.service'; import { logError } from '../shared/logger/error-logging'; import { logger } from '../shared/logger/logger'; +import type { MessageWithName } from '../shared/models/message/message-with-name'; +import type { TraitWithSlackId } from '../shared/db/models/Trait'; +import { TraitPersistenceService } from './trait.persistence.service'; export class TraitService { private readonly traitPersistenceService = new TraitPersistenceService(); @@ -38,4 +40,54 @@ export class TraitService { await this.webService.sendEphemeral(channelId, 'Sorry, something went wrong fetching your traits.', userId); } } + + public 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 trait of traits) { + const slackId = trait.slackId || 'unknown'; + if (!grouped.has(slackId)) grouped.set(slackId, []); + grouped.get(slackId)!.push(trait); + } + + const lines = Array.from(grouped.entries()) + .map(([slackId, userTraits]) => { + const name = nameMap.get(slackId) || slackId; + const traitLines = userTraits.map((trait) => `"${trait.content}"`).join(', '); + return `- ${name}: ${traitLines}`; + }) + .join('\n'); + + return `\ncore beliefs and stable traits for people in this conversation:\n${lines}\n`; + } + + public async fetchTraitContext( + participantSlackIds: string[], + teamId: string, + history: MessageWithName[], + ): Promise { + if (participantSlackIds.length === 0) return ''; + const traitsMap = await this.traitPersistenceService.getAllTraitsForUsers(participantSlackIds, teamId); + const traits = Array.from(traitsMap.values()).flat(); + return this.formatTraitContext(traits, history); + } + + public appendTraitContext(baseInstructions: string, traitContext: string): string { + if (!traitContext) return baseInstructions; + + // Insert trait data before so the checklist remains the last thing the model sees. + const verificationTag = ''; + const insertionPoint = baseInstructions.lastIndexOf(verificationTag); + if (insertionPoint !== -1) { + return `${baseInstructions.slice(0, insertionPoint)}${traitContext}\n\n${baseInstructions.slice(insertionPoint)}`; + } + + return `${baseInstructions}\n\n${traitContext}`; + } } From f895eafd7e87c007b2135df571744314f00a2c5d Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Mon, 20 Apr 2026 12:53:39 -0400 Subject: [PATCH 13/18] Fixed bad import --- packages/backend/src/trait/trait.persistence.service.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/trait/trait.persistence.service.spec.ts b/packages/backend/src/trait/trait.persistence.service.spec.ts index 2c01a06c..840c85cc 100644 --- a/packages/backend/src/trait/trait.persistence.service.spec.ts +++ b/packages/backend/src/trait/trait.persistence.service.spec.ts @@ -2,7 +2,7 @@ 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'; +import { Trait } from '../shared/db/models/Trait'; vi.mock('typeorm', async () => ({ getRepository: vi.fn(), From c61be7a7e984307d18f37674325d3d04691c833d Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Mon, 20 Apr 2026 12:54:38 -0400 Subject: [PATCH 14/18] Reused GATE_MODEL --- packages/backend/src/trait/trait.job.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/trait/trait.job.ts b/packages/backend/src/trait/trait.job.ts index e32c112d..3ab82a88 100644 --- a/packages/backend/src/trait/trait.job.ts +++ b/packages/backend/src/trait/trait.job.ts @@ -1,6 +1,6 @@ import { getRepository } from 'typeorm'; import { TraitPersistenceService } from './trait.persistence.service'; -import { TRAIT_EXTRACTION_PROMPT } from '../ai/ai.constants'; +import { GATE_MODEL, TRAIT_EXTRACTION_PROMPT } from '../ai/ai.constants'; import { AIService } from '../ai/ai.service'; import { MemoryPersistenceService } from '../ai/memory/memory.persistence.service'; import { SlackUser } from '../shared/db/models/SlackUser'; @@ -59,7 +59,7 @@ export class TraitJob { await this.regenerateTraitsForUsers(teamId, slackIds, async (input) => { return this.aiService.openAi.responses .create({ - model: 'gpt-4o-mini', + model: GATE_MODEL, instructions: TRAIT_EXTRACTION_PROMPT, input, user: `trait-job-${teamId}`, From a9ce4528a6335b08dd2770b12502bd335821e038 Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Mon, 20 Apr 2026 12:56:22 -0400 Subject: [PATCH 15/18] Updated redis in memory.job --- packages/backend/src/ai/memory/memory.job.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/ai/memory/memory.job.ts b/packages/backend/src/ai/memory/memory.job.ts index 9117c930..de30fb0b 100644 --- a/packages/backend/src/ai/memory/memory.job.ts +++ b/packages/backend/src/ai/memory/memory.job.ts @@ -87,7 +87,7 @@ export class MemoryJob { this.jobLogger.info(`Extraction lock active for ${channelId}-${teamId}, skipping`); return; } - await this.redis.setValueWithExpire(lockKey, 1, 'PX', 60 * 5); + await this.redis.setValueWithExpire(lockKey, 1, 'EX', 300000); // 5 Minutes try { const existingMemoriesMap = await this.memoryPersistenceService.getAllMemoriesForUsers( From 0f618badfe5d8a6410278c53cbbb47dd5bd1a977 Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Mon, 20 Apr 2026 14:32:01 -0400 Subject: [PATCH 16/18] Update packages/backend/src/ai/memory/memory.job.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/backend/src/ai/memory/memory.job.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/ai/memory/memory.job.ts b/packages/backend/src/ai/memory/memory.job.ts index de30fb0b..46836d0c 100644 --- a/packages/backend/src/ai/memory/memory.job.ts +++ b/packages/backend/src/ai/memory/memory.job.ts @@ -29,7 +29,7 @@ const extractAndParseOpenAiResponse = (response: OpenAI.Responses.Response): str export class MemoryJob { private historyService = new HistoryPersistenceService(); private memoryPersistenceService = new MemoryPersistenceService(); - private redis = new RedisPersistenceService(); + private redis = RedisPersistenceService.getInstance(); private aiService: AIService; private jobLogger = logger.child({ module: 'MemoryJob' }); From fb1a93cd8bf1a019c46723d5f645b5f5ba408383 Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Mon, 20 Apr 2026 14:32:13 -0400 Subject: [PATCH 17/18] Update packages/backend/src/trait/trait.service.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/backend/src/trait/trait.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/trait/trait.service.ts b/packages/backend/src/trait/trait.service.ts index d318f05f..3f4eb029 100644 --- a/packages/backend/src/trait/trait.service.ts +++ b/packages/backend/src/trait/trait.service.ts @@ -32,7 +32,7 @@ export class TraitService { const message = `Moonbeam's core traits about you:\n${formattedTraits}`; await this.webService.sendEphemeral(channelId, message, userId); } catch (e) { - logError(this.traitLogger, 'Failed to fetch traits for /ai/traits command', e, { + logError(this.traitLogger, 'Failed to fetch traits for /traits command', e, { userId, teamId, channelId, From 645c3c2262896c4cc8bb7de5acabe9c47210c43c Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Mon, 20 Apr 2026 14:41:13 -0400 Subject: [PATCH 18/18] Added handling for parseExtractionResults --- packages/backend/src/ai/memory/memory.job.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/ai/memory/memory.job.ts b/packages/backend/src/ai/memory/memory.job.ts index 46836d0c..1fbb739f 100644 --- a/packages/backend/src/ai/memory/memory.job.ts +++ b/packages/backend/src/ai/memory/memory.job.ts @@ -145,7 +145,11 @@ export class MemoryJob { private parseExtractionResults(trimmedResult: string): Array> | null { try { const parsed: Array> = JSON.parse(trimmedResult); - return parsed; + if (Array.isArray(parsed)) { + return parsed; + } + this.jobLogger.warn(`Extraction returned JSON but it was not an array: ${trimmedResult}`); + return null; } catch { this.jobLogger.warn(`Extraction returned malformed JSON: ${trimmedResult}`); return null;