diff --git a/.changeset/skip-welcome-engaged-users.md b/.changeset/skip-welcome-engaged-users.md new file mode 100644 index 0000000000..01554d803f --- /dev/null +++ b/.changeset/skip-welcome-engaged-users.md @@ -0,0 +1,6 @@ +--- +--- + +No protocol impact - internal behavior change only. + +Skip intro/welcome goals for highly engaged users (committee leaders, working group members, council members, active Slack users). diff --git a/server/src/addie/services/outbound-planner.ts b/server/src/addie/services/outbound-planner.ts index cdfcc79163..e861c1d6e2 100644 --- a/server/src/addie/services/outbound-planner.ts +++ b/server/src/addie/services/outbound-planner.ts @@ -142,6 +142,17 @@ export class OutboundPlanner { if (hasInsight) return false; // Already has this insight, skip goal } + // Skip intro/welcome goals for highly engaged users + // These users are clearly already part of the community - no need to ask "what brings you here?" + // Any of these indicators suggests the user is already engaged: + if (goal.category === 'information' && goal.success_insight_type === 'initial_interest') { + const caps = ctx.capabilities; + if (caps?.is_committee_leader) return false; + if (caps && caps.working_group_count > 0) return false; + if (caps && caps.council_count > 0) return false; + if (caps && caps.slack_message_count_30d >= 10) return false; + } + return true; } @@ -362,7 +373,7 @@ export class OutboundPlanner { if (caps.has_team_members) capabilityLines.push('✓ Has team members'); else capabilityLines.push('✗ No team members added'); - if (caps.is_committee_leader) capabilityLines.push('✓ Committee leader'); + // Committee leader is now shown in Role section, not here if (caps.slack_message_count_30d > 0) { capabilityLines.push(`Activity: ${caps.slack_message_count_30d} Slack messages in last 30 days`); @@ -371,19 +382,35 @@ export class OutboundPlanner { } } + // Build role/position line + const roleLines: string[] = []; + if (caps?.is_committee_leader) roleLines.push('Committee Leader'); + if (caps && caps.council_count > 0) roleLines.push(`Council Member (${caps.council_count})`); + if (caps && caps.working_group_count > 0) roleLines.push(`WG Member (${caps.working_group_count})`); + const roleStr = roleLines.length > 0 ? roleLines.join(', ') : 'Community member'; + + // Filter notes from insights for separate display + const notes = ctx.user.insights.filter(i => i.type === 'note'); + const otherInsights = ctx.user.insights.filter(i => i.type !== 'note'); + const insightStr = otherInsights.length > 0 + ? otherInsights.map(i => `${i.type}: ${i.value} (${i.confidence})`).join('\n - ') + : 'Nothing yet'; + return `You are helping decide what capability or feature to introduce to a member of AgenticAdvertising.org. ## User Context - Name: ${ctx.user.display_name ?? 'Unknown'} -- Company: ${ctx.company?.name ?? 'Unknown'} (${ctx.company?.type ?? 'unknown type'}) -- Account Status: ${ctx.user.is_mapped ? 'Linked' : 'Not linked'} +- Company: ${ctx.company?.name ?? 'Unknown'} +- Role in Community: ${roleStr} +- Account Status: ${ctx.user.is_mapped ? 'Linked' : 'Not linked'}, ${ctx.user.is_member ? 'Paying member' : 'Not yet a member'} - Engagement Score: ${ctx.user.engagement_score}/100 ## What They've Done (Capabilities) ${capabilityLines.length > 0 ? capabilityLines.map(l => ` ${l}`).join('\n') : ' No capability data available'} ## What We Know (Insights) - - ${userInsights} + - ${insightStr} +${notes.length > 0 ? `\n## Notes from Channel Conversations\n${notes.map(n => ` - ${n.value}`).join('\n')}` : ''} ## Available Goals (pick ONE) ${goals.map((g, i) => `${i + 1}. **${g.name}** (${g.category}) diff --git a/server/src/addie/services/passive-note-extractor.ts b/server/src/addie/services/passive-note-extractor.ts new file mode 100644 index 0000000000..9471032e88 --- /dev/null +++ b/server/src/addie/services/passive-note-extractor.ts @@ -0,0 +1,261 @@ +/** + * Passive Note Extractor Service + * + * Watches public channels Addie is in and extracts interesting tidbits + * about what people say - stored as text notes, not structured insights. + * + * Key differences from insight-extractor.ts: + * - Passive: doesn't respond, just observes + * - Simpler: only extracts 'note' type insights + * - Rate-limited: uses a queue to avoid overwhelming the LLM API + * - Conservative: only extracts notable/interesting statements + */ + +import Anthropic from '@anthropic-ai/sdk'; +import { logger } from '../../logger.js'; +import { InsightsDatabase, type InsightConfidence } from '../../db/insights-db.js'; +import { ModelConfig } from '../../config/models.js'; +import { invalidateInsightsCache } from '../insights-cache.js'; + +const insightsDb = new InsightsDatabase(); + +// Rate limiting: process one message every 5 seconds +const RATE_LIMIT_MS = 5000; +const MIN_MESSAGE_LENGTH = 50; + +// Queue for rate-limited processing +interface QueueItem { + slackUserId: string; + workosUserId?: string; + channelId: string; + channelName?: string; + messageText: string; + messageTs: string; +} + +const queue: QueueItem[] = []; +let processing = false; + +/** + * Check if a message is worth analyzing for notes + */ +function shouldAnalyzeForNotes(text: string): boolean { + // Too short + if (text.length < MIN_MESSAGE_LENGTH) return false; + + // Mostly mentions or links + const mentionCount = (text.match(/<[@#!][^>]+>/g) || []).length; + if (mentionCount > 2) return false; + + // Pure questions to the group (not revealing anything about themselves) + if (/^(does anyone|has anyone|can someone|who knows|what is|how do|where can)/i.test(text.trim())) { + return false; + } + + // Just a reaction or short acknowledgment + if (/^(lol|haha|nice|great|thanks|agreed|yep|yes|no|ok|okay|cool|interesting)[!?.]*$/i.test(text.trim())) { + return false; + } + + return true; +} + +/** + * Build prompt for extracting a notable tidbit + */ +function buildNoteExtractionPrompt(message: string, channelName?: string): string { + return `Analyze this public Slack message from ${channelName ? `#${channelName}` : 'a channel'}. + +**Message:** +${message} + +**Task:** +Determine if this message reveals something notable about the person who wrote it - their interests, what they're working on, their opinions, or their goals. + +We're NOT looking for: +- Generic comments or reactions +- Questions to the group +- Casual chit-chat +- Technical troubleshooting + +We ARE looking for: +- "I'm really interested in X" +- "We're building Y at my company" +- "Our focus is on Z" +- "I think the future is..." +- Strong opinions or perspectives on industry topics + +If the message IS notable, summarize what it reveals about the person in ONE sentence. +If it's NOT notable, respond with null. + +Return ONLY valid JSON: +{"note": "Mentioned being interested in X" | null}`; +} + +/** + * Extract a note from a message using Claude + */ +async function extractNote(item: QueueItem): Promise { + const apiKey = process.env.ADDIE_ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + logger.warn('Passive note extractor: No API key configured'); + return null; + } + + try { + const client = new Anthropic({ apiKey }); + const prompt = buildNoteExtractionPrompt(item.messageText, item.channelName); + + const response = await client.messages.create({ + model: ModelConfig.fast, + max_tokens: 150, + messages: [{ role: 'user', content: prompt }], + }); + + const content = response.content[0]; + if (content.type !== 'text') return null; + + // Parse JSON response + const cleaned = content.text.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim(); + const parsed = JSON.parse(cleaned); + + return parsed.note || null; + } catch (error) { + logger.warn({ error, slackUserId: item.slackUserId }, 'Passive note extraction failed'); + return null; + } +} + +/** + * Store a note as an insight + */ +async function storeNote( + slackUserId: string, + workosUserId: string | undefined, + note: string, + channelName: string | undefined, + messageTs: string +): Promise { + // Get the 'note' insight type ID + const noteType = await insightsDb.getInsightTypeByName('note'); + if (!noteType) { + logger.error('Note insight type not found - run migration 143_note_insight_type.sql'); + return; + } + + // Format the note with channel context + const formattedNote = channelName + ? `${note} (in #${channelName})` + : note; + + // Check for duplicate (same user, same basic note value) + const existingInsights = await insightsDb.getInsightsForUser(slackUserId); + const isDuplicate = existingInsights.some( + i => i.insight_type_name === 'note' && i.value.toLowerCase().includes(note.toLowerCase().slice(0, 30)) + ); + + if (isDuplicate) { + logger.debug({ slackUserId, note }, 'Skipping duplicate note'); + return; + } + + // Store the note + await insightsDb.addInsight({ + slack_user_id: slackUserId, + workos_user_id: workosUserId, + insight_type_id: noteType.id, + value: formattedNote, + confidence: 'medium' as InsightConfidence, // Passive observation = medium confidence + source_type: 'observation', + source_thread_id: undefined, + source_message_id: messageTs, + extracted_from: undefined, // Don't store the full message for privacy + }); + + // Invalidate cache for this user + invalidateInsightsCache(slackUserId); + + logger.info({ slackUserId, note: formattedNote }, 'Stored note from channel conversation'); +} + +/** + * Process the next item in the queue + */ +async function processNext(): Promise { + if (processing || queue.length === 0) return; + + processing = true; + const item = queue.shift()!; + + try { + const note = await extractNote(item); + if (note) { + await storeNote( + item.slackUserId, + item.workosUserId, + note, + item.channelName, + item.messageTs + ); + } + } catch (error) { + logger.warn({ error, item }, 'Error processing passive note extraction'); + } + + // Schedule next item after rate limit delay + setTimeout(() => { + processing = false; + processNext(); + }, RATE_LIMIT_MS); +} + +/** + * Queue a message for passive note extraction + * + * Call this from the message handler for public channel messages. + * The message will be processed asynchronously with rate limiting. + */ +export function queueForNoteExtraction(params: { + slackUserId: string; + workosUserId?: string; + channelId: string; + channelName?: string; + messageText: string; + messageTs: string; +}): void { + // Pre-filter before queueing + if (!shouldAnalyzeForNotes(params.messageText)) { + return; + } + + // Deduplicate: don't queue if same user+message already in queue + const isDuplicate = queue.some( + q => q.slackUserId === params.slackUserId && q.messageTs === params.messageTs + ); + if (isDuplicate) return; + + queue.push({ + slackUserId: params.slackUserId, + workosUserId: params.workosUserId, + channelId: params.channelId, + channelName: params.channelName, + messageText: params.messageText, + messageTs: params.messageTs, + }); + + logger.debug({ + slackUserId: params.slackUserId, + channelName: params.channelName, + queueLength: queue.length, + }, 'Queued message for passive note extraction'); + + // Start processing if not already running + processNext(); +} + +/** + * Get current queue length (for monitoring) + */ +export function getQueueLength(): number { + return queue.length; +} diff --git a/server/src/db/migrations/143_note_insight_type.sql b/server/src/db/migrations/143_note_insight_type.sql new file mode 100644 index 0000000000..19d6426078 --- /dev/null +++ b/server/src/db/migrations/143_note_insight_type.sql @@ -0,0 +1,20 @@ +-- Add 'note' insight type for storing interesting tidbits from channel conversations +-- These are free-form text observations, not structured data + +INSERT INTO member_insight_types (name, description, example_values, is_active, created_by) +VALUES ( + 'note', + 'Interesting tidbit or context about the person from channel conversations', + ARRAY[ + 'Mentioned being interested in doing more with user data', + 'Said they are building a buyer agent for programmatic', + 'Asked about measurement APIs for campaign attribution', + 'Shared that they are focused on sustainability initiatives' + ], + TRUE, + 'system' +) +ON CONFLICT (name) DO UPDATE SET + description = EXCLUDED.description, + example_values = EXCLUDED.example_values, + is_active = EXCLUDED.is_active; diff --git a/server/src/slack/events.ts b/server/src/slack/events.ts index 6aea9ded8e..f4d97d3bf4 100644 --- a/server/src/slack/events.ts +++ b/server/src/slack/events.ts @@ -24,6 +24,7 @@ import { type AppMentionEvent, type AssistantMessageEvent, } from '../addie/index.js'; +import { queueForNoteExtraction } from '../addie/services/passive-note-extractor.js'; const slackDb = new SlackDatabase(); const addieDb = new AddieDatabase(); @@ -410,6 +411,18 @@ export async function handleMessage(event: SlackMessageEvent): Promise { // Skip DMs (im) and private channels - only index public messages if (event.channel_type === 'channel' && event.text && event.text.length > 20) { await indexMessageForSearch(event); + + // Queue for passive note extraction (async, rate-limited) + // This extracts interesting tidbits from channel conversations + const channel = await getChannelInfo(event.channel); + queueForNoteExtraction({ + slackUserId: event.user, + workosUserId: mapping?.workos_user_id ?? undefined, + channelId: event.channel, + channelName: channel?.name, + messageText: event.text, + messageTs: event.ts, + }); } } catch (error) { logger.error({ error, userId: event.user }, 'Failed to record message activity');