From 21a6e9dc9dc060f79cfc547d2dbf2d7c6385a692 Mon Sep 17 00:00:00 2001 From: Yale Leber Date: Sat, 14 Mar 2026 21:51:11 -0400 Subject: [PATCH 1/2] Fix broken markdown rendering in AI Slack responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LLM responses containing standard markdown (### headers, **bold**, * bullet lists) were rendering as raw text in Slack because Slack uses its own mrkdwn format. The existing markdownToSlackMrkdwn() converter was incomplete and not applied to all code paths. Resolves this by adopting Slack's new markdown block type, which natively translates standard markdown server-side. Applied across all four AI text paths: /ai/text, /ai/prompt-with-history, @moonbeam, and redeployMoonbeam. Removes the manual converter and getChunks utility. Also upgrades @slack/web-api v6 → v7.15.0 with associated breaking change fixes. Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 158 +++++++---------- packages/backend/package.json | 4 +- packages/backend/src/ai/ai.service.spec.ts | 5 +- packages/backend/src/ai/ai.service.ts | 102 +++++------ .../src/ai/openai/openai.service.spec.ts | 160 +----------------- .../backend/src/ai/openai/openai.service.ts | 53 +----- .../src/shared/services/web/web.service.ts | 27 +-- .../backend/src/shared/util/getChunks.spec.ts | 144 ---------------- packages/backend/src/shared/util/getChunks.ts | 103 ----------- 9 files changed, 118 insertions(+), 638 deletions(-) delete mode 100644 packages/backend/src/shared/util/getChunks.spec.ts delete mode 100644 packages/backend/src/shared/util/getChunks.ts diff --git a/package-lock.json b/package-lock.json index a4636c95..04f70a5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1963,22 +1963,22 @@ } }, "node_modules/@slack/logger": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-3.0.0.tgz", - "integrity": "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-4.0.1.tgz", + "integrity": "sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ==", "license": "MIT", "dependencies": { - "@types/node": ">=12.0.0" + "@types/node": ">=18" }, "engines": { - "node": ">= 12.13.0", - "npm": ">= 6.12.0" + "node": ">= 18", + "npm": ">= 8.6.0" } }, "node_modules/@slack/types": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/@slack/types/-/types-2.19.0.tgz", - "integrity": "sha512-7+QZ38HGcNh/b/7MpvPG6jnw7mliV6UmrquJLqgdxkzJgQEYUcEztvFWRU49z0x4vthF0ixL5lTK601AXrS8IA==", + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/@slack/types/-/types-2.20.1.tgz", + "integrity": "sha512-eWX2mdt1ktpn8+40iiMc404uGrih+2fxiky3zBcPjtXKj6HLRdYlmhrPkJi7JTJm8dpXR6BWVWEDBXtaWMKD6A==", "license": "MIT", "engines": { "node": ">= 12.13.0", @@ -1986,55 +1986,40 @@ } }, "node_modules/@slack/web-api": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-6.13.0.tgz", - "integrity": "sha512-dv65crIgdh9ZYHrevLU6XFHTQwTyDmNqEqzuIrV+Vqe/vgiG6w37oex5ePDU1RGm2IJ90H8iOvHFvzdEO/vB+g==", - "license": "MIT", - "dependencies": { - "@slack/logger": "^3.0.0", - "@slack/types": "^2.11.0", - "@types/is-stream": "^1.1.0", - "@types/node": ">=12.0.0", - "axios": "^1.7.4", - "eventemitter3": "^3.1.0", - "form-data": "^2.5.0", + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-7.15.0.tgz", + "integrity": "sha512-va7zYIt3QHG1x9M/jqXXRPFMoOVlVSSRHC5YH+DzKYsrz5xUKOA3lR4THsu/Zxha9N1jOndbKFKLtr0WOPW1Vw==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.1", + "@slack/types": "^2.20.1", + "@types/node": ">=18", + "@types/retry": "0.12.0", + "axios": "^1.13.5", + "eventemitter3": "^5.0.1", + "form-data": "^4.0.4", "is-electron": "2.2.2", - "is-stream": "^1.1.0", - "p-queue": "^6.6.1", - "p-retry": "^4.0.0" + "is-stream": "^2", + "p-queue": "^6", + "p-retry": "^4", + "retry": "^0.13.1" }, "engines": { - "node": ">= 12.13.0", - "npm": ">= 6.12.0" + "node": ">= 18", + "npm": ">= 8.6.0" } }, "node_modules/@slack/web-api/node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, - "node_modules/@slack/web-api/node_modules/axios/node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/@slack/web-api/node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -2055,6 +2040,18 @@ } } }, + "node_modules/@slack/web-api/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@so-ric/colorspace": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", @@ -2221,15 +2218,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/is-stream": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@types/is-stream/-/is-stream-1.1.0.tgz", - "integrity": "sha512-jkZatu4QVbR60mpIzjINmtS1ZF4a/FqdTUTBeQDVOQ2PYyidtwFKr0B5G6ERukKwliq+7mIXvxyppwzG5EgRYg==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -2299,10 +2287,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "12.20.55", - "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", - "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==", - "license": "MIT" + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } }, "node_modules/@types/node-fetch": { "version": "2.6.13", @@ -2314,22 +2305,6 @@ "form-data": "^4.0.4" } }, - "node_modules/@types/node-fetch/node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -5395,9 +5370,9 @@ } }, "node_modules/eventemitter3": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", - "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, "node_modules/execa": { @@ -6068,20 +6043,19 @@ } }, "node_modules/form-data": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", - "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", - "mime-types": "^2.1.35", - "safe-buffer": "^5.2.1" + "mime-types": "^2.1.12" }, "engines": { - "node": ">= 0.12" + "node": ">= 6" } }, "node_modules/form-data-encoder": { @@ -7463,6 +7437,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10852,15 +10827,6 @@ } } }, - "node_modules/openai/node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, "node_modules/openai/node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -14708,7 +14674,7 @@ "license": "ISC", "dependencies": { "@google/genai": "^1.30.0", - "@slack/web-api": "^6.8.0", + "@slack/web-api": "^7.15.0", "axios": "^0.18.1", "body-parser": "^1.20.2", "decimal.js": "^10.6.0", @@ -14731,7 +14697,7 @@ "@types/jest": "^24.0.15", "@types/jest-when": "^2.7.0", "@types/lolex": "^3.1.1", - "@types/node": "^12.12.56", + "@types/node": "^18.0.0", "@types/sentiment": "^5.0.1", "@types/uuid": "^9.0.6", "dotenv": "^16.3.1", diff --git a/packages/backend/package.json b/packages/backend/package.json index 6bce7dac..4471f0fd 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -19,7 +19,7 @@ "license": "ISC", "dependencies": { "@google/genai": "^1.30.0", - "@slack/web-api": "^6.8.0", + "@slack/web-api": "^7.15.0", "axios": "^0.18.1", "body-parser": "^1.20.2", "decimal.js": "^10.6.0", @@ -42,7 +42,7 @@ "@types/jest": "^24.0.15", "@types/jest-when": "^2.7.0", "@types/lolex": "^3.1.1", - "@types/node": "^12.12.56", + "@types/node": "^18.0.0", "@types/sentiment": "^5.0.1", "@types/uuid": "^9.0.6", "dotenv": "^16.3.1", diff --git a/packages/backend/src/ai/ai.service.spec.ts b/packages/backend/src/ai/ai.service.spec.ts index 3cac77bd..c0b0992e 100644 --- a/packages/backend/src/ai/ai.service.spec.ts +++ b/packages/backend/src/ai/ai.service.spec.ts @@ -11,7 +11,6 @@ jest.mock('./openai/openai.service', () => ({ OpenAIService: jest.fn().mockImplementation(() => ({ generateText: jest.fn(), generateImage: jest.fn(), - convertAsterisks: jest.fn(), })), })); jest.mock('./ai.persistence', () => mockAiPersistenceService); @@ -234,7 +233,7 @@ describe('AIService', () => { true, ); expect(sendMessageMock).toHaveBeenCalledWith('channel123', 'Prompt', [ - { text: { text: 'Response with context', type: 'mrkdwn' }, type: 'section' }, + { type: 'markdown', text: 'Response with context' }, { type: 'divider' }, { elements: [ @@ -334,7 +333,7 @@ describe('AIService', () => { expect.arrayContaining([ expect.objectContaining({ type: 'image' }), expect.objectContaining({ type: 'header' }), - expect.objectContaining({ type: 'section' }), + expect.objectContaining({ type: 'markdown' }), ]), ); }); diff --git a/packages/backend/src/ai/ai.service.ts b/packages/backend/src/ai/ai.service.ts index c4f85801..bcefae3e 100644 --- a/packages/backend/src/ai/ai.service.ts +++ b/packages/backend/src/ai/ai.service.ts @@ -7,7 +7,6 @@ import { EventRequest, SlashCommandRequest } from '../shared/models/slack/slack- import { AIPersistenceService } from './ai.persistence'; import { KnownBlock } from '@slack/web-api'; import { WebService } from '../shared/services/web/web.service'; -import { getChunks } from '../shared/util/getChunks'; import { CORPO_SPEAK_INSTRUCTIONS, GENERAL_TEXT_INSTRUCTIONS, @@ -123,11 +122,8 @@ export class AIService { }, }, { - type: 'section', - text: { - type: 'mrkdwn', - text: `"${quote}"`, - }, + type: 'markdown', + text: `"${quote}"`, }, ]; this.webService.sendMessage('#muzzlefeedback', 'Moonbeam has been deployed.', blocks); @@ -215,35 +211,24 @@ export class AIService { return; } - const blocks: KnownBlock[] = []; - - const chunks = getChunks(result); - - if (chunks) { - chunks.forEach((chunk) => { - blocks.push({ - type: 'section', - text: { + const blocks: KnownBlock[] = [ + { + type: 'markdown', + text: result, + }, + { + type: 'divider', + }, + { + type: 'context', + elements: [ + { type: 'mrkdwn', - text: `${chunk}`, + text: `:technologist: _Context-aware prompt generated by <@${request.user_id}> | "${request.text}"_ :technologist:`, }, - }); - }); - } - - blocks.push({ - type: 'divider', - }); - - blocks.push({ - type: 'context', - elements: [ - { - type: 'mrkdwn', - text: `:technologist: _Context-aware prompt generated by <@${request.user_id}> | "${request.text}"_ :technologist:`, - }, - ], - }); + ], + }, + ]; this.webService.sendMessage(request.channel_id, request.text, blocks).catch((e) => { this.aiServiceLogger.error(e); @@ -283,8 +268,14 @@ export class AIService { .generateText(input, 'Moonbeam', MOONBEAM_SYSTEM_INSTRUCTIONS) .then((result) => { if (result) { + const blocks: KnownBlock[] = [ + { + type: 'markdown', + text: result, + }, + ]; this.webService - .sendMessage(channelId, result) + .sendMessage(channelId, result, blocks) .then(() => this.redis.setHasParticipated(teamId, channelId)) .catch((e) => this.aiServiceLogger.error('Error sending AI Participation message:', e)); } @@ -329,35 +320,24 @@ export class AIService { sendGptText(text: string | undefined, userId: string, teamId: string, channelId: string, query: string): void { if (text) { - const blocks: KnownBlock[] = []; - - const chunks = getChunks(text); - - if (chunks) { - chunks.forEach((chunk) => { - blocks.push({ - type: 'section', - text: { + const blocks: KnownBlock[] = [ + { + type: 'markdown', + text: text, + }, + { + type: 'divider', + }, + { + type: 'context', + elements: [ + { type: 'mrkdwn', - text: `${chunk}`, + text: `:altman: _Generated by <@${userId}> | "${query}"_ :altman:`, }, - }); - }); - } - - blocks.push({ - type: 'divider', - }); - - blocks.push({ - type: 'context', - elements: [ - { - type: 'mrkdwn', - text: `:altman: _Generated by <@${userId}> | "${query}"_ :altman:`, - }, - ], - }); + ], + }, + ]; this.webService.sendMessage(channelId, text, blocks).catch((e) => { this.aiServiceLogger.error(e); diff --git a/packages/backend/src/ai/openai/openai.service.spec.ts b/packages/backend/src/ai/openai/openai.service.spec.ts index c065f9bb..ae0e0c8a 100644 --- a/packages/backend/src/ai/openai/openai.service.spec.ts +++ b/packages/backend/src/ai/openai/openai.service.spec.ts @@ -62,7 +62,7 @@ describe('OpenAIService', () => { const result = await service.generateText('Say hello', 'user123'); - expect(result).toBe('Hello *world*! This is _italic_.'); + expect(result).toBe('Hello **world**! This is *italic*.'); }); it('should return undefined when no output_text is found', async () => { @@ -156,164 +156,6 @@ describe('OpenAIService', () => { }); }); - describe('markdownToSlackMrkdwn', () => { - it('should convert bold markdown to Slack format', () => { - const input = 'This is **bold** text'; - const result = service.markdownToSlackMrkdwn(input); - expect(result).toBe('This is *bold* text'); - }); - - it('should convert italic markdown to Slack format', () => { - const input = 'This is *italic* text'; - const result = service.markdownToSlackMrkdwn(input); - expect(result).toBe('This is _italic_ text'); - }); - - it('should preserve code blocks', () => { - const input = 'This is `code` text'; - const result = service.markdownToSlackMrkdwn(input); - expect(result).toBe('This is `code` text'); - }); - - it('should convert links to Slack format', () => { - const input = 'Check out [Google](https://google.com)'; - const result = service.markdownToSlackMrkdwn(input); - expect(result).toBe('Check out '); - }); - - it('should convert images to Slack format', () => { - const input = 'Look at this ![cat](https://example.com/cat.jpg)'; - const result = service.markdownToSlackMrkdwn(input); - expect(result).toBe('Look at this '); - }); - - it('should handle multiple formatting types in one string', () => { - const input = 'This is **bold** and *italic* with `code` and [link](https://test.com)'; - const result = service.markdownToSlackMrkdwn(input); - expect(result).toBe('This is *bold* and _italic_ with `code` and '); - }); - - it('should return undefined for undefined input', () => { - const result = service.markdownToSlackMrkdwn(undefined); - expect(result).toBeUndefined(); - }); - - it('should return empty string for empty input', () => { - const result = service.markdownToSlackMrkdwn(''); - expect(result).toBe(''); - }); - - it('should handle text with no markdown', () => { - const input = 'Just plain text'; - const result = service.markdownToSlackMrkdwn(input); - expect(result).toBe('Just plain text'); - }); - - it('should handle nested formatting correctly', () => { - const input = 'This is **bold with *italic* inside**'; - const result = service.markdownToSlackMrkdwn(input); - expect(result).toBe('This is *bold with _italic_ inside*'); - }); - - it('should convert H1 headings to bold format', () => { - const input = '# Main Heading'; - const result = service.markdownToSlackMrkdwn(input); - expect(result).toBe('*Main Heading*'); - }); - - it('should convert H2 headings to bold format', () => { - const input = '## Secondary Heading'; - const result = service.markdownToSlackMrkdwn(input); - expect(result).toBe('*Secondary Heading*'); - }); - - it('should convert H3 headings to italic format', () => { - const input = '### Tertiary Heading'; - const result = service.markdownToSlackMrkdwn(input); - expect(result).toBe('_Tertiary Heading_'); - }); - - it('should handle multiple headings in text', () => { - const input = `# Main Title - ## Subtitle - ### Section - Some content here`; - const expected = `*Main Title* - *Subtitle* - _Section_ - Some content here`; - const result = service.markdownToSlackMrkdwn(input); - expect(result).toBe(expected); - }); - - it('should handle multiple new lines with different formatting', () => { - const input = `# Heading 1 - ## Heading 2 - ### Heading 3`; - const expected = `*Heading 1* - *Heading 2* - _Heading 3_`; - const result = service.markdownToSlackMrkdwn(input); - expect(result).toBe(expected); - }); - - it('should handle multiple new lines with the same formatting (bold)', () => { - const input = `**This should - All Be - Bold**`; - const expected = `*This should - All Be - Bold*`; - const result = service.markdownToSlackMrkdwn(input); - expect(result).toBe(expected); - }); - - it('shold handle multiple new lines with the same formatting (italic)', () => { - const input = `*This should - All Be - Italic*`; - const expected = `_This should - All Be - Italic_`; - const result = service.markdownToSlackMrkdwn(input); - expect(result).toBe(expected); - }); - - it('should handle multiple lines with the same formatting (bold and italic)', () => { - const input = `**This should - All Be - Bold** and *Italic*`; - const expected = `*This should - All Be - Bold* and _Italic_`; - const result = service.markdownToSlackMrkdwn(input); - expect(result).toBe(expected); - }); - - it('should handle headings with other formatting', () => { - const input = '# **Bold** heading with *italic*'; - const result = service.markdownToSlackMrkdwn(input); - expect(result).toBe('**Bold* heading with _italic_*'); - }); - - it('should not convert headings without space after hash', () => { - const input = '#NotAHeading'; - const result = service.markdownToSlackMrkdwn(input); - expect(result).toBe('#NotAHeading'); - }); - - it('should handle headings at different positions in text', () => { - const input = `Some text - ## Middle Heading - More text`; - const expected = `Some text - *Middle Heading* - More text`; - const result = service.markdownToSlackMrkdwn(input); - expect(result).toBe(expected); - }); - }); - describe('error handling', () => { it('should handle OpenAI API errors in generateText', async () => { mockOpenAI.responses.create.mockRejectedValue(new Error('API Error')); diff --git a/packages/backend/src/ai/openai/openai.service.ts b/packages/backend/src/ai/openai/openai.service.ts index 9a73d443..3cdd1112 100644 --- a/packages/backend/src/ai/openai/openai.service.ts +++ b/packages/backend/src/ai/openai/openai.service.ts @@ -30,7 +30,7 @@ export class OpenAIService { (block: ResponseOutputText | ResponseOutputRefusal) => block.type === 'output_text', ) as ResponseOutputText )?.text; - return this.markdownToSlackMrkdwn(outputText?.trim()); + return outputText?.trim(); }); }; @@ -49,55 +49,4 @@ export class OpenAIService { }); }; - markdownToSlackMrkdwn = (text?: string) => { - if (!text) { - return text; - } - - // Convert ![alt text](image url) to (do this first to avoid conflicts with links) - text = text.replace(/!\[(.*?)\]\((.*?)\)/g, '<$2|$1>'); - - // Convert [link](url) to - text = text.replace(/\[(.*?)\]\((.*?)\)/g, '<$2|$1>'); - - // Convert `code` to `code` (no change needed, but process before bold/italic to avoid conflicts) - text = text.replace(/`(.*?)`/g, '`$1`'); - - // Use a more robust approach for bold and italic - // First, temporarily replace **bold** with a placeholder to avoid conflicts - const boldPlaceholder = 'BOLD_PLACEHOLDER'; - const boldMatches: string[] = []; - - // Extract bold text and replace with placeholders, but first process italic within bold - text = text.replace(/\*\*([\s\S]*?)\*\*/g, (_, content) => { - // Process italic formatting within the bold content - const processedContent = content.replace(/\*([^*]+?)\*/g, '_$1_'); - boldMatches.push(processedContent); - return `${boldPlaceholder}${boldMatches.length - 1}${boldPlaceholder}`; - }); - - // Now convert remaining single asterisks to italic (underscores) - text = text.replace(/\*([^*]+?)\*/g, '_$1_'); - - // Restore bold text with Slack formatting - text = text.replace(new RegExp(`${boldPlaceholder}(\\d+)${boldPlaceholder}`, 'g'), (_, index) => { - return `*${boldMatches[parseInt(index)]}*`; - }); - - // Convert #, ## and ### headings to Slack's mrkdwn format - text = text.replace(/^(\s*)(#{1,3})\s+(.*)$/gm, (_, leadingSpace, hashes, content) => { - const level = hashes.length; - switch (level) { - case 1: - case 2: - return `${leadingSpace}*${content}*`; // Preserve leading whitespace - case 3: - return `${leadingSpace}_${content}_`; // Preserve leading whitespace - default: - return `${leadingSpace}${content}`; - } - }); - - return text; - }; } diff --git a/packages/backend/src/shared/services/web/web.service.ts b/packages/backend/src/shared/services/web/web.service.ts index 41821b13..a630c74e 100644 --- a/packages/backend/src/shared/services/web/web.service.ts +++ b/packages/backend/src/shared/services/web/web.service.ts @@ -1,7 +1,6 @@ import { ChatDeleteArguments, ChatPostMessageArguments, - FilesUploadArguments, WebAPICallResult, WebClient, ChatPostEphemeralArguments, @@ -30,7 +29,6 @@ export class WebService { token: muzzleToken, channel, ts, - as_user: true, }; this.web.chat @@ -77,16 +75,13 @@ export class WebService { */ public sendMessage(channel: string, text: string, blocks?: Block[] | KnownBlock[]): Promise { const token: string | undefined = process.env.MUZZLE_BOT_USER_TOKEN; - const postRequest: ChatPostMessageArguments = { + const postRequest = { token, channel, text, unfurl_links: false, - }; - - if (blocks) { - postRequest.blocks = blocks; - } + ...(blocks && { blocks }), + } as ChatPostMessageArguments; return this.web.chat .postMessage(postRequest) @@ -111,7 +106,7 @@ export class WebService { } public getAllUsers(): Promise { - return this.web.users.list(); + return this.web.users.list({}); } public getAllChannels(): Promise { @@ -119,22 +114,18 @@ export class WebService { } public uploadFile(channel: string, content: string, title: string, userId: string): void { - const muzzleToken: string | undefined = process.env.MUZZLE_BOT_USER_TOKEN; - const uploadRequest: FilesUploadArguments = { - channels: channel, + this.web.filesUploadV2({ + channel_id: channel, content, - filetype: 'auto', title, + filename: `${title}.txt`, initial_comment: title, - token: muzzleToken, - }; - - this.web.files.upload(uploadRequest).catch((e: unknown) => { + }).catch((e: unknown) => { this.logger.error(e); const options: ChatPostEphemeralArguments = { channel, text: - (e as Record>).data.error === 'not_in_channel' + (e as Record>).data?.error === 'not_in_channel' ? `Oops! I tried to post the stats you requested but it looks like I haven't been added to that channel yet. Can you please add me? Just type \`@muzzle\` in the channel!` : `Oops! I tried to post the stats you requested but it looks like something went wrong. Please try again later.`, user: userId, diff --git a/packages/backend/src/shared/util/getChunks.spec.ts b/packages/backend/src/shared/util/getChunks.spec.ts deleted file mode 100644 index 7b11e48b..00000000 --- a/packages/backend/src/shared/util/getChunks.spec.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { getChunks, MAX_CHUNK_SIZE, splitByWords, splitLongWord, splitSentenceByWords } from './getChunks'; - -describe('getChunks', () => { - it('should just return text if length is <= to 2920', () => { - const text = 'a '.repeat(1460); - const result = getChunks(text); - expect(result).toEqual([text]); - }); - - it('returns the whole text if under the limit', () => { - const text = 'Short text.'; - expect(getChunks(text)).toEqual([text]); - }); - - it('splits text at sentence boundaries when possible', () => { - const sentence = 'A sentence. '; - const text = sentence.repeat(Math.ceil(MAX_CHUNK_SIZE / sentence.length) + 2); - const chunks = getChunks(text); - expect(chunks.every((chunk) => chunk.length <= MAX_CHUNK_SIZE)).toBe(true); - // Should split at sentence boundaries - expect(chunks.length).toBeGreaterThan(1); - expect(chunks.join(' ')).toContain('A sentence.'); - }); - - it('splits a single long sentence by words if needed', () => { - const longWord = 'a'.repeat(MAX_CHUNK_SIZE - 10); - const text = `${longWord} ${longWord} ${longWord}`; - const chunks = getChunks(text); - expect(chunks.every((chunk) => chunk.length <= MAX_CHUNK_SIZE)).toBe(true); - expect(chunks.length).toBeGreaterThan(1); - }); - - it('handles a sentence longer than the chunk size', () => { - const longSentence = 'a'.repeat(MAX_CHUNK_SIZE + 100) + '.'; - const text = longSentence + ' Short sentence.'; - const chunks = getChunks(text); - expect( - chunks.every((chunk) => { - return chunk.length <= MAX_CHUNK_SIZE; - }), - ).toBe(true); - expect(chunks.length).toBeGreaterThan(1); - expect(chunks[chunks.length - 1]).toContain('Short sentence.'); - }); - - it('handles text with no punctuation', () => { - const text = 'a '.repeat(MAX_CHUNK_SIZE + 100); - const chunks = getChunks(text); - expect(chunks.every((chunk) => chunk.length <= MAX_CHUNK_SIZE)).toBe(true); - expect(chunks.length).toBeGreaterThan(1); - }); - - it('handles empty string', () => { - expect(getChunks('')).toEqual(['']); - }); - - it('handles text with various punctuation', () => { - const text = 'Hello! How are you? I am fine. Thanks for asking!'; - expect(getChunks(text)).toEqual([text]); - }); - - it('handles words longer than chunk size', () => { - const longWord = 'a'.repeat(MAX_CHUNK_SIZE + 10); - const text = `${longWord} end.`; - const chunks = getChunks(text); - expect(chunks[0].length).toBeLessThanOrEqual(MAX_CHUNK_SIZE); - expect(chunks.join(' ')).toContain('end.'); - }); -}); - -describe('splitByWords', () => { - it('splits text into chunks by words', () => { - const word = 'a'.repeat(100); - const text = Array(40).fill(word).join(' '); - const chunks = splitByWords(text); - expect(chunks.every((chunk) => chunk.length <= MAX_CHUNK_SIZE)).toBe(true); - expect(chunks.join(' ')).toBe(text); - }); - - it('handles a single word longer than MAX_CHUNK_SIZE', () => { - const longWord = 'x'.repeat(MAX_CHUNK_SIZE + 10); - const chunks = splitByWords(longWord); - expect(chunks.length).toBe(2); - expect(chunks[0].length).toBe(MAX_CHUNK_SIZE); - expect(chunks[1].length).toBe(10); - }); - - it('returns empty array for empty string', () => { - expect(splitByWords('')).toEqual([]); - }); -}); - -describe('splitSentenceByWords', () => { - it('splits a long sentence by words into chunks', () => { - const word = 'b'.repeat(200); - const sentence = Array(20).fill(word).join(' '); - const chunks: string[] = []; - splitSentenceByWords(sentence, chunks); - expect(chunks.every((chunk) => chunk.length <= MAX_CHUNK_SIZE)).toBe(true); - expect(chunks.join(' ')).toBe(sentence); - }); - - it('handles a word longer than MAX_CHUNK_SIZE in a sentence', () => { - const longWord = 'y'.repeat(MAX_CHUNK_SIZE + 5); - const sentence = `short ${longWord} end`; - const chunks: string[] = []; - splitSentenceByWords(sentence, chunks); - expect(chunks.some((chunk) => chunk.includes('short'))).toBe(true); - expect(chunks.some((chunk) => chunk.includes('end'))).toBe(true); - expect(chunks.some((chunk) => chunk.length === MAX_CHUNK_SIZE)).toBe(true); - }); - - it('handles empty sentence', () => { - const chunks: string[] = []; - splitSentenceByWords('', chunks); - expect(chunks).toEqual([]); - }); -}); - -describe('splitLongWord', () => { - it('splits a long word into fixed-size chunks', () => { - const longWord = 'z'.repeat(MAX_CHUNK_SIZE * 2 + 5); - const chunks: string[] = []; - splitLongWord(longWord, chunks); - expect(chunks.length).toBe(3); - expect(chunks[0].length).toBe(MAX_CHUNK_SIZE); - expect(chunks[1].length).toBe(MAX_CHUNK_SIZE); - expect(chunks[2].length).toBe(5); - expect(chunks.join('')).toBe(longWord); - }); - - it('handles word shorter than MAX_CHUNK_SIZE', () => { - const word = 'shortword'; - const chunks: string[] = []; - splitLongWord(word, chunks); - expect(chunks).toEqual([word]); - }); - - it('handles empty word', () => { - const chunks: string[] = []; - splitLongWord('', chunks); - expect(chunks).toEqual([]); - }); -}); diff --git a/packages/backend/src/shared/util/getChunks.ts b/packages/backend/src/shared/util/getChunks.ts deleted file mode 100644 index e880b8ce..00000000 --- a/packages/backend/src/shared/util/getChunks.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { split as splitSentences } from 'sentence-splitter'; - -export const MAX_CHUNK_SIZE = 2920; - -export function getChunks(text: string): string[] { - if (text.length <= MAX_CHUNK_SIZE) return [text]; - - // Try to split at sentence boundaries - const sentences = splitSentences(text) - .filter((token) => token.type === 'Sentence') - .map((token) => token.raw); - - if (!sentences) return splitByWords(text); - - const chunks: string[] = []; - let currentChunk = ''; - - for (const sentence of sentences) { - const trimmedSentence = sentence.trim(); - // If adding this sentence would exceed the chunk size, flush currentChunk - if ((currentChunk + trimmedSentence).length > MAX_CHUNK_SIZE) { - if (currentChunk.trim()) { - chunks.push(currentChunk.trim()); - currentChunk = ''; - } - - // If the sentence itself is too long, split by words - if (trimmedSentence.length > MAX_CHUNK_SIZE) { - splitSentenceByWords(trimmedSentence, chunks); - continue; // currentChunk is already flushed - } else { - currentChunk = trimmedSentence.trim(); - } - } else { - currentChunk += ` ${trimmedSentence.trim()}`; - } - } - - if (currentChunk.trim()) { - chunks.push(currentChunk.trim()); - } - - return chunks.map((chunk) => chunk.trim()); -} - -export function splitByWords(text: string): string[] { - const words = text.split(' '); - const chunks: string[] = []; - let currentChunk = ''; - - for (const word of words) { - // If adding this word would exceed the chunk size, flush currentChunk - if ((currentChunk ? currentChunk.length + 1 : 0) + word.length > MAX_CHUNK_SIZE) { - if (currentChunk.trim()) { - chunks.push(currentChunk.trim()); - currentChunk = ''; - } - if (word.length > MAX_CHUNK_SIZE) { - splitLongWord(word, chunks); - } else { - currentChunk = word; - } - } else { - currentChunk += (currentChunk ? ' ' : '') + word; - } - } - - if (currentChunk.trim()) { - chunks.push(currentChunk.trim()); - } - - return chunks; -} - -export function splitSentenceByWords(sentence: string, chunks: string[]) { - let wordChunk = ''; - for (const word of sentence.split(' ')) { - if ((wordChunk ? wordChunk.length + 1 : 0) + word.length > MAX_CHUNK_SIZE) { - if (wordChunk.trim()) { - chunks.push(wordChunk.trim()); - wordChunk = ''; - } - if (word.length > MAX_CHUNK_SIZE) { - splitLongWord(word, chunks); - } else { - wordChunk = word; - } - } else { - wordChunk += (wordChunk ? ' ' : '') + word; - } - } - if (wordChunk.trim()) { - chunks.push(wordChunk.trim()); - } -} - -export function splitLongWord(word: string, chunks: string[]) { - let start = 0; - while (start < word.length) { - chunks.push(word.slice(start, start + MAX_CHUNK_SIZE)); - start += MAX_CHUNK_SIZE; - } -} From 76fd0be860c21b50cbe26afb27b13270760f4cf5 Mon Sep 17 00:00:00 2001 From: Yale Leber Date: Sat, 14 Mar 2026 22:06:34 -0400 Subject: [PATCH 2/2] Improve AI context quality for better LLM reasoning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LLM responses were degraded by poor context assembly: user mentions passed as raw Slack IDs (<@U123ABC>) left the model unable to identify who was being referenced, no channel context meant the model couldn't calibrate tone for #politics vs #dev, and /prompt-with-history embedded conversation history in system instructions rather than user input — violating the model's expected separation between behavioral guidance and content to reason over. The @moonbeam path also sent the triggering message twice (once from the DB, once appended), wasting tokens that could carry useful conversation history. Resolves this by resolving mentions to display names so the model can track conversational participants, injecting channel name and topic into system prompts for tone calibration, restructuring prompt-with-history to place history in user input where the model expects content, deduplicating the triggering message, adding token-aware history truncation to maximize useful context within budget, replacing the hardcoded userIdId != 39 exclusion with a dynamic lookup, and giving /ai/text basic channel and user context. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/backend/src/ai/ai.constants.ts | 16 ++++ packages/backend/src/ai/ai.service.spec.ts | 44 +++++++--- packages/backend/src/ai/ai.service.ts | 73 ++++++++++------- .../services/history.persistence.service.ts | 24 +++++- .../src/shared/util/contextUtils.spec.ts | 80 +++++++++++++++++++ .../backend/src/shared/util/contextUtils.ts | 57 +++++++++++++ 6 files changed, 253 insertions(+), 41 deletions(-) create mode 100644 packages/backend/src/shared/util/contextUtils.spec.ts create mode 100644 packages/backend/src/shared/util/contextUtils.ts diff --git a/packages/backend/src/ai/ai.constants.ts b/packages/backend/src/ai/ai.constants.ts index 2ecef0f9..a58e15de 100644 --- a/packages/backend/src/ai/ai.constants.ts +++ b/packages/backend/src/ai/ai.constants.ts @@ -3,6 +3,13 @@ export const GPT_IMAGE_MODEL = 'dall-e-3'; export const MAX_AI_REQUESTS_PER_DAY = 5; export const GENERAL_TEXT_INSTRUCTIONS = 'Generate a response with a focus on being helpful and succinct.'; + +export const getGeneralTextInstructions = (channelName?: string, userName?: string): string => { + const parts = ['Generate a response with a focus on being helpful and succinct.']; + if (channelName) parts.push(`You are responding in #${channelName}.`); + if (userName) parts.push(`The user asking is ${userName}.`); + return parts.join(' '); +}; export const CORPO_SPEAK_INSTRUCTIONS = `Translate the following text into a Corporate Jargon that still maintains the general meaning of the text. Be sure to respond with only the translated text.`; // Deprecated... for now. // export const PARTICIPATION_INSTRUCTIONS = ` @@ -61,9 +68,18 @@ export const GET_TAGGED_MESSAGE_INSTRUCTIONS = (message: string) => { return MOONBEAM_SYSTEM_INSTRUCTIONS + `\n\nrespond to this message: ${message}`; }; +/** @deprecated Use PROMPT_WITH_HISTORY_INSTRUCTIONS + buildPromptWithHistoryInput instead */ export const getHistoryInstructions = (history: string): string => { return `Use this conversation history to respond to the user's prompt:\n${history}`; }; +export const PROMPT_WITH_HISTORY_INSTRUCTIONS = + 'You are Moonbeam, a helpful Slack assistant. You receive conversation history from the channel followed by a user\'s question. Use the conversation context to inform your answer. Be thorough but concise.'; + +export const buildPromptWithHistoryInput = (history: string, prompt: string, channelName?: string): string => { + const channelContext = channelName ? `You are responding in #${channelName}.\n\n` : ''; + return `${channelContext}Conversation history:\n${history}\n\n---\nUser's question: ${prompt}`; +}; + export const REDPLOY_MOONBEAM_TEXT_PROMPT = `Provide a cryptic message about the future and humanity's role in it.`; export const REDPLOY_MOONBEAM_IMAGE_PROMPT = `An image depicting yourself with the understanding that your name is Moonbeam and you identify as a female. The art style can be any choice you would like. Feel free to be creative, and do not feel that you must always present yourself in humanoid form. Please do not include any text in the image.`; diff --git a/packages/backend/src/ai/ai.service.spec.ts b/packages/backend/src/ai/ai.service.spec.ts index c0b0992e..cf2b3c50 100644 --- a/packages/backend/src/ai/ai.service.spec.ts +++ b/packages/backend/src/ai/ai.service.spec.ts @@ -16,6 +16,7 @@ jest.mock('./openai/openai.service', () => ({ jest.mock('./ai.persistence', () => mockAiPersistenceService); jest.mock('../shared/services/history.persistence.service'); jest.mock('../shared/services/web/web.service'); +jest.mock('../shared/services/slack/slack.service'); describe('AIService', () => { let aiService: AIService; @@ -24,6 +25,9 @@ describe('AIService', () => { jest.clearAllMocks(); aiService = new AIService(); aiService.aiServiceLogger = mockLogger.Logger() as unknown as Logger; + // Default mocks for slack service lookups + jest.spyOn(aiService.slackService, 'getChannelName').mockResolvedValue('general'); + jest.spyOn(aiService.slackService, 'getUserNameById').mockResolvedValue('testuser'); }); describe('decrementDaiyRequests', () => { @@ -163,14 +167,14 @@ describe('AIService', () => { }); describe('formatHistory', () => { - it('should format message history correctly with timestamps', () => { + it('should format message history correctly with timestamps', async () => { const history: MessageWithName[] = [ { name: 'John', message: 'Hello there', createdAt: new Date('2024-01-15T10:30:00') }, { name: 'Jane', message: 'How are you?', createdAt: new Date('2024-01-15T10:31:00') }, { name: 'Bob', message: 'Good morning!', createdAt: new Date('2024-01-15T10:32:00') }, ] as MessageWithName[]; - const result = aiService.formatHistory(history); + const result = await aiService.formatHistory(history, 'team123'); expect(result).toContain('John: Hello there'); expect(result).toContain('Jane: How are you?'); @@ -179,21 +183,37 @@ describe('AIService', () => { expect(result).toMatch(/\[\d{2}:\d{2}\s[AP]M\]/); }); - it('should handle messages without timestamps', () => { + it('should handle messages without timestamps', async () => { const history: MessageWithName[] = [ { name: 'John', message: 'Hello there' }, { name: 'Jane', message: 'How are you?' }, ] as MessageWithName[]; - const result = aiService.formatHistory(history); + const result = await aiService.formatHistory(history, 'team123'); expect(result).toBe('John: Hello there\nJane: How are you?'); }); - it('should handle empty history', () => { - const result = aiService.formatHistory([]); + it('should handle empty history', async () => { + const result = await aiService.formatHistory([], 'team123'); expect(result).toBe('[No recent messages in channel]'); }); + + it('should resolve user mentions to display names', async () => { + const history: MessageWithName[] = [ + { name: 'John', message: 'Hey <@U123ABC> check this out' }, + ] as MessageWithName[]; + + jest.spyOn(aiService.slackService, 'getUserNameById').mockImplementation(async (userId) => { + if (userId === 'U123ABC') return 'steve'; + return undefined; + }); + + const result = await aiService.formatHistory(history, 'team123'); + + expect(result).toContain('@steve'); + expect(result).not.toContain('<@U123ABC>'); + }); }); describe('promptWithHistory', () => { @@ -222,10 +242,11 @@ describe('AIService', () => { expect(setInflightMock).toHaveBeenCalledWith('user123', 'team123'); expect(setDailyRequestsMock).toHaveBeenCalledWith('user123', 'team123'); + // History is now in user input (not system instructions), with channel context expect(generateTextMock).toHaveBeenCalledWith( - 'Prompt', + expect.stringContaining('Conversation history:'), 'user123', - `Use this conversation history to respond to the user's prompt:\nJohn: Hello\nJane: Hi`, + expect.stringContaining('You are Moonbeam'), ); expect(removeInflightMock).toHaveBeenCalledWith('user123', 'team123'); expect(getHistoryMock).toHaveBeenCalledWith( @@ -267,9 +288,10 @@ describe('AIService', () => { maxMessages: 200, timeWindowMinutes: 120, }); - // Should pass tagged message in input (not instructions) and use static system instructions + // Tagged message is already in history (written to DB before participate runs), no duplicate append + // System instructions include channel context expect(generateTextMock).toHaveBeenCalledWith( - expect.stringContaining('---\n[Tagged message to respond to]:\ntagged message'), + expect.stringContaining('John: Hello'), 'Moonbeam', expect.stringContaining('you are moonbeam'), ); @@ -403,6 +425,8 @@ describe('AIService', () => { const isUserMuzzledMock = jest .spyOn(aiService.muzzlePersistenceService, 'isUserMuzzled') .mockResolvedValue(false); + jest.spyOn(aiService.slackService, 'containsTag').mockReturnValue(true); + jest.spyOn(aiService.slackService, 'isUserMentioned').mockReturnValue(true); const participateMock = jest.spyOn(aiService, 'participate').mockResolvedValue(); await aiService.handle(request); diff --git a/packages/backend/src/ai/ai.service.ts b/packages/backend/src/ai/ai.service.ts index bcefae3e..34788ecc 100644 --- a/packages/backend/src/ai/ai.service.ts +++ b/packages/backend/src/ai/ai.service.ts @@ -9,13 +9,15 @@ import { KnownBlock } from '@slack/web-api'; import { WebService } from '../shared/services/web/web.service'; import { CORPO_SPEAK_INSTRUCTIONS, - GENERAL_TEXT_INSTRUCTIONS, + getGeneralTextInstructions, MOONBEAM_SYSTEM_INSTRUCTIONS, - getHistoryInstructions, + PROMPT_WITH_HISTORY_INSTRUCTIONS, + buildPromptWithHistoryInput, MAX_AI_REQUESTS_PER_DAY, REDPLOY_MOONBEAM_IMAGE_PROMPT, REDPLOY_MOONBEAM_TEXT_PROMPT, } from './ai.constants'; +import { resolveUserMentions, truncateToTokenBudget } from '../shared/util/contextUtils'; import { logger } from '../shared/logger/logger'; import { SlackService } from '../shared/services/slack/slack.service'; import { MuzzlePersistenceService } from '../muzzle/muzzle.persistence.service'; @@ -54,9 +56,12 @@ export class AIService { ): Promise { await this.redis.setInflight(userId, teamId); await this.redis.setDailyRequests(userId, teamId); + const channelName = await this.slackService.getChannelName(channelId, teamId); + const userName = await this.slackService.getUserNameById(userId, teamId); + const instructions = getGeneralTextInstructions(channelName || undefined, userName || undefined); const textPromise = isGemini - ? this.geminiService.generateText(text, GENERAL_TEXT_INSTRUCTIONS) - : this.openAiService.generateText(text, userId, GENERAL_TEXT_INSTRUCTIONS); + ? this.geminiService.generateText(text, instructions) + : this.openAiService.generateText(text, userId, instructions); return textPromise .then(async (result) => { await this.redis.removeInflight(userId, teamId); @@ -174,13 +179,20 @@ export class AIService { }); } - public formatHistory(history: MessageWithName[]): string { + private getUserNameLookup(teamId: string): (userId: string) => Promise { + return (userId: string) => this.slackService.getUserNameById(userId, teamId); + } + + public async formatHistory(history: MessageWithName[], teamId: string, maxTokens = 8000): Promise { if (!history || history.length === 0) { return '[No recent messages in channel]'; } - return history - .map((x) => { + const truncated = truncateToTokenBudget(history, maxTokens); + const lookupFn = this.getUserNameLookup(teamId); + + const lines = await Promise.all( + truncated.map(async (x) => { const timestamp = x.createdAt ? new Date(x.createdAt).toLocaleTimeString('en-US', { hour: '2-digit', @@ -188,9 +200,12 @@ export class AIService { }) : ''; const prefix = timestamp ? `[${timestamp}] ` : ''; - return `${prefix}${x.name}: ${x.message}`; - }) - .join('\n'); + const resolvedMessage = await resolveUserMentions(x.message, lookupFn); + return `${prefix}${x.name}: ${resolvedMessage}`; + }), + ); + + return lines.join('\n'); } public async promptWithHistory(request: SlashCommandRequest, isGemini = false): Promise { @@ -198,11 +213,12 @@ export class AIService { await this.redis.setInflight(user_id, team_id); await this.redis.setDailyRequests(user_id, team_id); const history: MessageWithName[] = await this.historyService.getHistory(request, true); - const formattedHistory: string = this.formatHistory(history); - const systemInstructions = getHistoryInstructions(formattedHistory); + const channelName = await this.slackService.getChannelName(request.channel_id, team_id); + const formattedHistory = await this.formatHistory(history, team_id); + const input = buildPromptWithHistoryInput(formattedHistory, prompt, channelName || undefined); const textGenPromise = isGemini - ? this.geminiService.generateText(prompt, systemInstructions) - : this.openAiService.generateText(prompt, user_id, systemInstructions); + ? this.geminiService.generateText(input, PROMPT_WITH_HISTORY_INSTRUCTIONS) + : this.openAiService.generateText(input, user_id, PROMPT_WITH_HISTORY_INSTRUCTIONS); return textGenPromise .then(async (result) => { await this.redis.removeInflight(user_id, team_id); @@ -246,26 +262,27 @@ export class AIService { }); } - public async participate(teamId: string, channelId: string, taggedMessage: string): Promise { + public async participate(teamId: string, channelId: string, _taggedMessage?: string): Promise { await this.redis.setParticipationInFlight(channelId, teamId); // Use getHistoryWithOptions to include Moonbeam's own messages and use larger context window - const history = await this.historyService - .getHistoryWithOptions({ - teamId, - channelId, - maxMessages: 200, - timeWindowMinutes: 120, - // No excludeUserId - include all messages including Moonbeam's for conversational continuity - }) - .then((x) => this.formatHistory(x)); + const rawHistory = await this.historyService.getHistoryWithOptions({ + teamId, + channelId, + maxMessages: 200, + timeWindowMinutes: 120, + // No excludeUserId - include all messages including Moonbeam's for conversational continuity + }); - // Append tagged message to history as user input (not in system instructions) - // This prevents prompt injection and follows best practices - const input = `${history}\n\n---\n[Tagged message to respond to]:\n${taggedMessage}`; + const history = await this.formatHistory(rawHistory, teamId); + const channelName = await this.slackService.getChannelName(channelId, teamId); + const channelContext = channelName ? `\nyou are responding in #${channelName}.` : ''; + const instructions = MOONBEAM_SYSTEM_INSTRUCTIONS + channelContext; + // The tagged message is already the last entry in history (written to DB before participate runs). + // No need to append it again — just pass the history as input. return this.openAiService - .generateText(input, 'Moonbeam', MOONBEAM_SYSTEM_INSTRUCTIONS) + .generateText(history, 'Moonbeam', instructions) .then((result) => { if (result) { const blocks: KnownBlock[] = [ diff --git a/packages/backend/src/shared/services/history.persistence.service.ts b/packages/backend/src/shared/services/history.persistence.service.ts index 49432d4e..87d2dd26 100644 --- a/packages/backend/src/shared/services/history.persistence.service.ts +++ b/packages/backend/src/shared/services/history.persistence.service.ts @@ -13,6 +13,20 @@ export interface HistoryOptions { } export class HistoryPersistenceService { + private moonbeamDbId: number | null = null; + + private async getMoonbeamDbId(teamId: string): Promise { + if (this.moonbeamDbId !== null) { + return this.moonbeamDbId; + } + const moonbeam = await getRepository(SlackUser).findOne({ + where: { slackId: 'ULG8SJRFF', teamId }, + }); + if (moonbeam) { + this.moonbeamDbId = moonbeam.id; + } + return this.moonbeamDbId; + } async logHistory(request: EventRequest): Promise { // This is a bandaid to stop workflows from breaking the service. if (typeof request.event.user !== 'string' || request.event.type === 'user_profile_changed') { @@ -47,12 +61,16 @@ export class HistoryPersistenceService { const teamId = request.team_id; const channel = request.channel_id; const interval = isDaily ? 'INTERVAL 1 DAY' : 'INTERVAL 1 HOUR'; + const moonbeamId = await this.getMoonbeamDbId(teamId); + const userFilter = moonbeamId !== null ? 'AND message.userIdId != ?' : ''; + const baseParams = moonbeamId !== null ? [teamId, channel, moonbeamId] : [teamId, channel]; + const query = ` ( SELECT message.*, slack_user.name FROM message INNER JOIN slack_user ON slack_user.id=message.userIdId - WHERE message.userIdId != 39 AND message.teamId=? AND message.channel=? AND message.message != '' + WHERE message.teamId=? AND message.channel=? AND message.message != '' ${userFilter} ORDER BY message.createdAt DESC LIMIT 100 ) @@ -61,11 +79,11 @@ export class HistoryPersistenceService { SELECT message.*, slack_user.name FROM message INNER JOIN slack_user ON slack_user.id=message.userIdId - WHERE message.userIdId != 39 AND message.teamId=? AND message.channel=? AND message.message != '' AND createdAt >= DATE_SUB(NOW(), ${interval}) + WHERE message.teamId=? AND message.channel=? AND message.message != '' ${userFilter} AND createdAt >= DATE_SUB(NOW(), ${interval}) ORDER BY createdAt DESC ) ORDER BY createdAt ASC;`; - return getRepository(Message).query(query, [teamId, channel, teamId, channel]); + return getRepository(Message).query(query, [...baseParams, ...baseParams]); } /** diff --git a/packages/backend/src/shared/util/contextUtils.spec.ts b/packages/backend/src/shared/util/contextUtils.spec.ts new file mode 100644 index 00000000..1e0c8ecc --- /dev/null +++ b/packages/backend/src/shared/util/contextUtils.spec.ts @@ -0,0 +1,80 @@ +import { resolveUserMentions, truncateToTokenBudget } from './contextUtils'; +import { MessageWithName } from '../models/message/message-with-name'; + +describe('resolveUserMentions', () => { + it('should replace Slack user IDs with display names', async () => { + const text = 'Hey <@U123ABC> what do you think about <@U456DEF> idea?'; + const lookupFn = async (userId: string) => { + const names: Record = { U123ABC: 'steve', U456DEF: 'yale' }; + return names[userId]; + }; + const result = await resolveUserMentions(text, lookupFn); + expect(result).toBe('Hey @steve what do you think about @yale idea?'); + }); + + it('should leave unresolved IDs as-is', async () => { + const text = 'Hey <@UUNKNOWN> how are you?'; + const lookupFn = async () => undefined; + const result = await resolveUserMentions(text, lookupFn); + expect(result).toBe('Hey <@UUNKNOWN> how are you?'); + }); + + it('should handle text with no mentions', async () => { + const text = 'Just a normal message'; + const lookupFn = async () => undefined; + const result = await resolveUserMentions(text, lookupFn); + expect(result).toBe('Just a normal message'); + }); + + it('should handle channel and here mentions', async () => { + const text = 'Hey and check this out'; + const lookupFn = async () => undefined; + const result = await resolveUserMentions(text, lookupFn); + expect(result).toBe('Hey @channel and @here check this out'); + }); + + it('should handle mentions with display name suffix', async () => { + const text = 'Hey <@U123ABC|steve> how are you?'; + const lookupFn = async () => 'steve'; + const result = await resolveUserMentions(text, lookupFn); + expect(result).toBe('Hey @steve how are you?'); + }); +}); + +describe('truncateToTokenBudget', () => { + const makeMsg = (name: string, message: string, minutesAgo: number): MessageWithName => + ({ + name, + message, + createdAt: new Date(Date.now() - minutesAgo * 60000), + }) as MessageWithName; + + it('should return all messages when under budget', () => { + const messages = [makeMsg('alice', 'hello', 5), makeMsg('bob', 'hi there', 4)]; + const result = truncateToTokenBudget(messages, 1000); + expect(result).toHaveLength(2); + }); + + it('should drop oldest messages when over budget', () => { + const messages = [ + makeMsg('alice', 'a'.repeat(2000), 10), // ~500 tokens, oldest + makeMsg('bob', 'b'.repeat(2000), 5), // ~500 tokens + makeMsg('carol', 'c'.repeat(200), 1), // ~50 tokens, newest + ]; + const result = truncateToTokenBudget(messages, 600); + expect(result).toHaveLength(2); + expect(result[0].name).toBe('bob'); + expect(result[1].name).toBe('carol'); + }); + + it('should return empty array when no messages fit', () => { + const messages = [makeMsg('alice', 'a'.repeat(8000), 1)]; + const result = truncateToTokenBudget(messages, 10); + expect(result).toHaveLength(0); + }); + + it('should handle empty input', () => { + const result = truncateToTokenBudget([], 1000); + expect(result).toHaveLength(0); + }); +}); diff --git a/packages/backend/src/shared/util/contextUtils.ts b/packages/backend/src/shared/util/contextUtils.ts new file mode 100644 index 00000000..3049e37a --- /dev/null +++ b/packages/backend/src/shared/util/contextUtils.ts @@ -0,0 +1,57 @@ +import { MessageWithName } from '../models/message/message-with-name'; + +/** + * Resolves Slack user mention markup (<@USERID>) to display names (@username). + * Also resolves and to @channel and @here. + */ +export async function resolveUserMentions( + text: string, + lookupFn: (userId: string) => Promise, +): Promise { + // Resolve and + let resolved = text.replace(//g, '@channel').replace(//g, '@here'); + + // Find all <@USERID> patterns (with optional |displayname suffix) + const mentionRegex = /<@(\w+)(?:\|[^>]*)?>/g; + const matches = [...resolved.matchAll(mentionRegex)]; + + for (const match of matches) { + const userId = match[1]; + const displayName = await lookupFn(userId); + if (displayName) { + resolved = resolved.replace(match[0], `@${displayName}`); + } + } + + return resolved; +} + +/** + * Estimates token count using chars/4 heuristic. + */ +function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +/** + * Truncates message history from the oldest end to fit within a token budget. + * Messages are assumed to be in chronological order (oldest first). + * Returns a new array — does not mutate the input. + */ +export function truncateToTokenBudget(messages: MessageWithName[], maxTokens: number): MessageWithName[] { + const costs = messages.map((m) => estimateTokens(`${m.name}: ${m.message}`)); + const totalTokens = costs.reduce((sum, c) => sum + c, 0); + + if (totalTokens <= maxTokens) { + return messages; + } + + let remaining = totalTokens; + let startIndex = 0; + while (startIndex < messages.length && remaining > maxTokens) { + remaining -= costs[startIndex]; + startIndex++; + } + + return messages.slice(startIndex); +}