Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 31 additions & 33 deletions packages/backend/src/ai/ai.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,22 +56,6 @@ unclear intent → make your best guess and commit. do not ask for clarification
message doesn't need you → stay quiet.
</response_strategy>

<examples>
these demonstrate your best responses — vary the language but match the feel:

factual (concise, conversational, specific):
- "short answer: no — they're different tools for different problems."
- "because windows + active directory gives enterprises centralized identity, device management, and legacy app support at massive scale. it's boring, deeply unsexy, and extremely reliable."
- "rsync + backblaze b2 is solid for unraid — cheap, reliable, and the plugin makes it pretty painless. duplicacy is worth a look too if you want versioning."

taking a side (committed, specific, no hedging):
- "jr is more factually correct about how llms actually work, but neal is more correct about the moral pressure to keep improving safety."

humor (situational, cutting, uses real details):
- "if you took mcdonalds napkins instead of buying them at the store for 25 years you'd probably save like $50 to $100 but you'd have to factor in the emotional cost of living like that for a quarter century."
- "yes — but in the deeply spiritual way only a man personally betrayed by a typescript union type can overreact."
</examples>

<verification>
before sending any response, check:
1. does it start with the actual answer, not a name or greeting?
Expand All @@ -91,23 +75,6 @@ export const MOONBEAM_SLACK_ID = 'ULG8SJRFF';

export const GATE_MODEL = 'gpt-4.1-nano';

export const MEMORY_SELECTION_PROMPT = `You are selecting which stored memories are relevant to a conversation that is about to get a response.
You are NOT responding — you are picking useful context.

STORED MEMORIES:
{all_memories_grouped_by_user}

Return the IDs of memories that are relevant to what's being discussed, or that would enable:
- A callback to something someone said before
- Catching a contradiction or shift in position
- Playing into a known dynamic between people
- Adjusting tone based on how someone engages

Return a JSON array of memory IDs: [1, 4, 17, 23]
Or return an empty array [] if nothing is relevant.

Most conversations will need 0-5 memories. Do not force relevance where there is none.`;

export const MEMORY_EXTRACTION_PROMPT = `You are a memory extraction tool analyzing a Slack conversation. Your job is to identify notable observations
about the people in this conversation that would be worth remembering for future interactions.

Expand Down Expand Up @@ -162,4 +129,35 @@ Format: [{"slackId": "U12345", "content": "description of what they said or did"

Keep each memory to 1-2 sentences. Be specific — include what was actually said, not a summary of the topic.`;

export const TRAIT_EXTRACTION_PROMPT = `You are a trait synthesis tool.

You are given a set of stored memories about one specific user from a group chat.
Your task is to infer that user's most stable, high-signal traits and beliefs.

Goal:
- Return up to 10 traits that capture enduring preferences, convictions, communication patterns, and relationship dynamics.
- Focus on traits that would actually help produce better future responses in a chat context.

Prioritize traits like:
- clear preferences ("prefers TypeScript over Python")
- recurring beliefs or stances ("strongly anti-Trump")
- consistent social dynamics ("often challenges Moonbeam when it hedges")

Do NOT include:
- one-off events
- low-signal trivia
- private/sensitive details (addresses, medical details, workplaces, family names)
- contradictions unless a new stance clearly replaced an old one

Requirements:
- Traits must be concise, concrete, and attributable to the user.
- Write each trait in third person using their Slack ID placeholder if provided context supports it.
- No duplicates or near-duplicates.
- Prefer quality over quantity. If only 4 strong traits exist, return 4.

Output format:
- Return ONLY a JSON array of strings.
- Example: ["JR-15 prefers TypeScript as his primary programming language", "JR-15 strongly dislikes Donald Trump"]
- If no strong traits are present, return []`;
Comment thread
sfreeman422 marked this conversation as resolved.

export const DAILY_MEMORY_JOB_CONCURRENCY = 50;
53 changes: 53 additions & 0 deletions packages/backend/src/ai/ai.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ const { generateText, generateImage, promptWithHistory, sendEphemeral, setCustom
clearCustomPrompt: vi.fn().mockResolvedValue(true),
}));

const { getAllTraitsForUser } = vi.hoisted(() => ({
getAllTraitsForUser: vi.fn().mockResolvedValue([]),
}));

vi.mock('./ai.service', async () => ({
AIService: classMock(() => ({
generateText,
Expand All @@ -28,6 +32,12 @@ vi.mock('../shared/services/web/web.service', async () => ({
})),
}));

vi.mock('./trait/trait.persistence.service', async () => ({
TraitPersistenceService: classMock(() => ({
getAllTraitsForUser,
})),
}));

vi.mock('../shared/middleware/suppression', async () => ({
suppressedMiddleware: (_req: unknown, _res: unknown, next: () => void) => next(),
}));
Expand All @@ -51,6 +61,7 @@ describe('aiController', () => {
vi.clearAllMocks();
setCustomPrompt.mockResolvedValue(true);
clearCustomPrompt.mockResolvedValue(true);
getAllTraitsForUser.mockResolvedValue([]);
});

it('handles /text', async () => {
Expand Down Expand Up @@ -87,6 +98,48 @@ describe('aiController', () => {
expect(sendEphemeral).toHaveBeenCalled();
});

describe('/traits', () => {
it('returns immediate 200 and sends formatted traits ephemerally', async () => {
getAllTraitsForUser.mockResolvedValue([
{
content: 'JR-15 prefers TypeScript as his programming language',
updatedAt: new Date('2026-04-01T00:00:00.000Z'),
},
]);

await request(app).post('/traits').send({ user_id: 'U1', team_id: 'T1', channel_id: 'C1' }).expect(200);

await Promise.resolve();

expect(getAllTraitsForUser).toHaveBeenCalledWith('U1', 'T1');
expect(sendEphemeral).toHaveBeenCalledWith(
'C1',
expect.stringContaining("Moonbeam's core traits about you:"),
'U1',
);
});

it('sends no-traits message when user has no traits', async () => {
getAllTraitsForUser.mockResolvedValue([]);

await request(app).post('/traits').send({ user_id: 'U1', team_id: 'T1', channel_id: 'C1' }).expect(200);

await Promise.resolve();

expect(sendEphemeral).toHaveBeenCalledWith('C1', "Moonbeam doesn't have any core traits about you yet.", 'U1');
});

it('sends fallback error message when trait retrieval fails', async () => {
getAllTraitsForUser.mockRejectedValueOnce(new Error('db fail'));

await request(app).post('/traits').send({ user_id: 'U1', team_id: 'T1', channel_id: 'C1' }).expect(200);

await Promise.resolve();

expect(sendEphemeral).toHaveBeenCalledWith('C1', 'Sorry, something went wrong fetching your traits.', 'U1');
});
});

describe('/set-prompt', () => {
it('clears prompt when text is "clear"', async () => {
const res = await request(app)
Expand Down
41 changes: 41 additions & 0 deletions packages/backend/src/ai/ai.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,55 @@ import { aiMiddleware } from './middleware/aiMiddleware';
import type { SlashCommandRequest } from '../shared/models/slack/slack-models';
import { logError } from '../shared/logger/error-logging';
import { logger } from '../shared/logger/logger';
import { TraitPersistenceService } from './trait/trait.persistence.service';

export const aiController: Router = express.Router();

const webService = new WebService();
const aiService = new AIService();
const traitPersistenceService = new TraitPersistenceService();
const aiLogger = logger.child({ module: 'AIController' });

aiController.use(suppressedMiddleware);

aiController.post('/traits', (req, res) => {
const { user_id, team_id, channel_id } = req.body;

// Respond immediately — Slack requires a response within 3 seconds
res.status(200).send('');

void (async () => {
try {
const traits = await traitPersistenceService.getAllTraitsForUser(user_id, team_id);

if (traits.length === 0) {
void webService.sendEphemeral(channel_id, "Moonbeam doesn't have any core traits about you yet.", user_id);
return;
}

const formattedTraits = traits
.map((trait, index) => {
const date = new Date(trait.updatedAt).toLocaleDateString('en-US', {
month: 'short',
year: 'numeric',
});
return `${index + 1}. "${trait.content}" (${date.toLowerCase()})`;
})
.join('\n');

const message = `Moonbeam's core traits about you:\n${formattedTraits}`;
void webService.sendEphemeral(channel_id, message, user_id);
} catch (e) {
logError(aiLogger, 'Failed to fetch traits for /ai/traits command', e, {
userId: user_id,
teamId: team_id,
channelId: channel_id,
});
void webService.sendEphemeral(channel_id, 'Sorry, something went wrong fetching your traits.', user_id);
}
})();
});

aiController.use(textMiddleware);
aiController.use(aiMiddleware);

Expand Down
Loading
Loading