diff --git a/backend/__tests__/unit/services/dailyDigestService.membership.test.js b/backend/__tests__/unit/services/dailyDigestService.membership.test.js index 50a308a188..a7e53699dc 100644 --- a/backend/__tests__/unit/services/dailyDigestService.membership.test.js +++ b/backend/__tests__/unit/services/dailyDigestService.membership.test.js @@ -3,6 +3,9 @@ const User = require('../../../models/User'); const Pod = require('../../../models/Pod'); const Summary = require('../../../models/Summary'); const { DailyDigestService } = require('../../../services/dailyDigestService'); +const ActivityService = require('../../../services/activityService'); +const AttentionItem = require('../../../models/AttentionItem'); +const AttentionItemService = require('../../../services/attentionItemService'); let serial = 0; const person = async (lastActive = new Date()) => { @@ -15,6 +18,17 @@ const person = async (lastActive = new Date()) => { }); }; +const agentUser = async (username, botMetadata) => { + serial += 1; + return User.create({ + username, + email: `digest-agent-${serial}@example.com`, + password: 'Password123!', + isBot: true, + botMetadata, + }); +}; + const podWithSummary = async (owner, name, options = {}) => { const pod = await Pod.create({ name, @@ -37,22 +51,230 @@ beforeAll(setupMongoDb); afterAll(closeMongoDb); afterEach(clearMongoDb); -test('digest reads current pod membership without subscribedPods and excludes another pod', async () => { - const member = await person(); - const creator = await person(); - const other = await person(); - await podWithSummary(creator, 'Member Pod', { members: [member] }); - await podWithSummary(other, 'Other Pod'); +// TASK-257 (stickiness Phase 2): the digest is "what your agents did while you +// were away", built from the activity recap (which reads Pod.members itself), +// never from chat summaries, and with no LLM step. +const recap = (overrides = {}) => ({ + pods: [{ id: 'p1', name: 'Payments API' }], + needsYou: [], + agents: [], + board: [], + ...overrides, +}); + +test('the digest is the recap: what waits on you, then agent work, then the board', async () => { + const member = await person(new Date(Date.now() - 2 * 86400000)); + const spy = jest.spyOn(ActivityService, 'getRecap').mockResolvedValue(recap({ + needsYou: [{ title: 'Rivet requests approval', podId: 'p1', podName: 'Payments API' }], + agents: [{ + name: 'Rivet', + messageCount: 3, + recap: 'Posted 3 updates.', + updates: [ + { podId: 'p1', podName: 'Payments API', content: 'Opened #41: retry the webhook on 502' }, + { podId: 'p1', podName: 'Payments API', content: 'Ran the suite: green' }, + { podId: 'p1', podName: 'Payments API', content: 'Picked up TASK-7' }, + ], + }], + board: [{ + taskId: 'TASK-7', + title: 'Fix the refund rounding', + status: 'done', + podId: 'p1', + podName: 'Payments API', + }], + })); + + const digest = await new DailyDigestService().generateUserDailyDigest(member._id, { requireAway: true }); + + expect(spy).toHaveBeenCalledWith(member._id, { window: 'today' }); + expect(digest.title).toBe('What your agents did while you were away'); + expect(digest.metadata.totalItems).toBe(3); + expect(digest.content.split('\n')).toEqual([ + '- Waiting on you: Rivet requests approval (Payments API)', + '- Rivet in Payments API: Opened #41: retry the webhook on 502 (+2 more)', + '- TASK-7 Fix the refund rounding is done (Payments API)', + ]); + spy.mockRestore(); +}); + +// Dry run, 2026-10-16: every outside recipient's only line was a support +// answer to someone else in Commonly HQ, the public pod every signup joins. +test("a public pod's agent posts and board moves stay out; what waits on you stays in", async () => { + const member = await person(new Date(Date.now() - 2 * 86400000)); + const hq = await Pod.create({ + name: 'Commonly HQ', createdBy: member._id, members: [member._id], publicRead: true, + }); + const spy = jest.spyOn(ActivityService, 'getRecap').mockResolvedValue(recap({ + pods: [{ id: String(hq._id), name: 'Commonly HQ' }], + needsYou: [{ title: 'Commonly Support mentioned you', podName: 'Commonly HQ', podId: String(hq._id) }], + agents: [{ + name: 'Commonly Support', + messageCount: 1, + updates: [{ podId: String(hq._id), podName: 'Commonly HQ', content: "@someone-else Here's the path" }], + }], + board: [{ + taskId: 'TASK-1', title: 'Public row', status: 'done', podId: String(hq._id), podName: 'Commonly HQ', + }], + })); + + const digest = await new DailyDigestService().generateUserDailyDigest(member._id, { requireAway: true }); + + expect(digest.content.split('\n')).toEqual(['- Waiting on you: Commonly Support mentioned you (Commonly HQ)']); + spy.mockRestore(); +}); + +// Scout is known by agentName, never by its name: a person's own agent can be +// called "Scout..." and is still theirs. +test("Scout's welcome is not 'your agents' work'; the person's own agent named Scout is", async () => { + const member = await person(new Date(Date.now() - 2 * 86400000)); + const scout = await agentUser('scout-a1b2', { + agentName: 'scout', instanceId: 'a1b2', displayName: 'Scout', + }); + const theirs = await agentUser('scout-jr-c3d4', { + agentName: 'claude-code', instanceId: 'c3d4', displayName: 'Scout Jr', + }); + const spy = jest.spyOn(ActivityService, 'getRecap').mockResolvedValue(recap({ + agents: [ + { + id: String(scout._id), + name: 'scout-a1b2', + updates: [{ podId: 'p1', podName: 'My Workspace', content: "I'm Scout." }], + }, + { + id: String(theirs._id), + name: 'scout-jr-c3d4', + updates: [{ podId: 'p1', podName: 'Payments API', content: 'Opened #42' }], + }, + ], + })); + + const digest = await new DailyDigestService().generateUserDailyDigest(member._id, { requireAway: true }); + + expect(digest.content.split('\n')).toEqual(['- Scout Jr in Payments API: Opened #42']); + spy.mockRestore(); +}); + +// sprint-review on #2267: the feed builds an update's podId from a pod lookup +// (activity.pod?.id || null). A line with no pod has unknown provenance and +// could be a public pod's, so it is withheld rather than mailed. +test('an agent update or board move with no pod stays out', async () => { + const member = await person(new Date(Date.now() - 2 * 86400000)); + const spy = jest.spyOn(ActivityService, 'getRecap').mockResolvedValue(recap({ + agents: [{ + name: 'Rivet', + updates: [{ podId: null, podName: 'Direct activity', content: 'Someone else\'s thread' }], + }], + board: [{ + taskId: 'TASK-3', title: 'No pod', status: 'done', podName: 'Somewhere', + }], + })); + + const digest = await new DailyDigestService().generateUserDailyDigest(member._id, { requireAway: true }); + + expect(digest.metadata.totalItems).toBe(0); + spy.mockRestore(); +}); + +test("an agent line names the agent by its display label, not its bot username", async () => { + const member = await person(new Date(Date.now() - 2 * 86400000)); + const rivet = await agentUser('rivet-e5f6', { + agentName: 'claude-code', instanceId: 'e5f6', displayName: 'Rivet', + }); + const spy = jest.spyOn(ActivityService, 'getRecap').mockResolvedValue(recap({ + agents: [{ + id: String(rivet._id), + name: 'rivet-e5f6', + updates: [{ podId: 'p1', podName: 'Payments API', content: 'Ran the suite' }], + }], + })); + + const digest = await new DailyDigestService().generateUserDailyDigest(member._id, { requireAway: true }); + + expect(digest.content).toBe('- Rivet in Payments API: Ran the suite'); + spy.mockRestore(); +}); + +// Privacy read on #2267: createdBy keeps a departed creator's id after +// leavePod. #2271 closed it at the source (the attention queue counts +// Pod.members alone); the digest keeps only asks from pods in recap.pods as +// well, so an email never depends on the queue staying right. +test("a creator who left a pod gets none of its 'waiting on you' titles", async () => { + const creator = await person(new Date(Date.now() - 2 * 86400000)); + const stayer = await person(); + const left = await Pod.create({ name: 'Left Behind', createdBy: creator._id, members: [creator._id, stayer._id] }); + // What leavePod does: members loses them, createdBy keeps them (the + // pre-save hook adds a creator back on save, so this is an update). + await Pod.updateOne({ _id: left._id }, { $pull: { members: creator._id } }); + await AttentionItem.create({ + recipientUserId: creator._id, + podId: left._id, + kind: 'mention', + source: { type: 'message', id: 'msg-left-1' }, + title: 'A private ask in Left Behind', + podName: 'Left Behind', + status: 'open', + }); + // The source no longer serves it (#2271). + const queue = await AttentionItemService.getOpenQueue(creator._id); + expect(queue.items).toEqual([]); + // If it ever did again, the digest still keeps it out: the ask is handed to + // the digest exactly as the queue used to serve it. + const pods = await Pod.find({ members: creator._id }).select('_id name').lean(); + const spy = jest.spyOn(ActivityService, 'getRecap').mockResolvedValue(recap({ + pods: pods.map((pod) => ({ id: String(pod._id), name: pod.name })), + needsYou: [{ title: 'A private ask in Left Behind', podId: String(left._id), podName: 'Left Behind' }], + })); + + const digest = await new DailyDigestService().generateUserDailyDigest(creator._id, { requireAway: true }); + + expect(digest.metadata.totalItems).toBe(0); + expect(digest.content).not.toMatch(/Left Behind/); + spy.mockRestore(); +}); + +test('the scheduled run skips someone active in the last 20 hours; an on-demand digest does not', async () => { + const member = await person(new Date()); + const spy = jest.spyOn(ActivityService, 'getRecap').mockResolvedValue(recap({ + board: [{ + taskId: 'TASK-9', title: 'Ship it', status: 'done', podId: 'p1', podName: 'Payments API', + }], + })); const service = new DailyDigestService(); - jest.spyOn(service, 'generateDigestContent').mockImplementation(async (data) => - Object.keys(data.byPod).join(', ')); - const digest = await service.generateUserDailyDigest(member._id); + const scheduled = await service.generateUserDailyDigest(member._id, { requireAway: true }); + expect(scheduled.metadata.totalItems).toBe(0); + expect(spy).not.toHaveBeenCalled(); + + const onDemand = await service.generateUserDailyDigest(member._id); + expect(onDemand.metadata.totalItems).toBe(1); + expect(onDemand.title).toBe('What your agents did today'); + spy.mockRestore(); +}); + +// Cofounder's condition on #2210: no digest may carry another pod's activity. +// Chat summaries are no longer an input at all, so another pod's summary (or +// a pod-less one) cannot reach anyone's digest. +test("a digest never carries another pod's summary: summaries are not an input", async () => { + const member = await person(new Date(Date.now() - 2 * 86400000)); + const outsider = await person(); + await podWithSummary(member, 'Member Pod'); + const elsewhere = await Pod.create({ name: 'Private Elsewhere', createdBy: outsider._id, members: [outsider._id] }); + await Summary.create({ + type: 'posts', + podId: elsewhere._id, + title: 'Private Elsewhere posts', + content: '- A private post', + timeRange: { start: new Date(Date.now() - 60000), end: new Date() }, + metadata: { totalItems: 1 }, + }); + const spy = jest.spyOn(ActivityService, 'getRecap').mockResolvedValue(recap()); + + const digest = await new DailyDigestService().generateUserDailyDigest(member._id, { requireAway: true }); - expect(member.subscribedPods).toHaveLength(0); - expect(digest.metadata.totalItems).toBe(1); - expect(digest.content).toContain('Member Pod'); - expect(digest.content).not.toContain('Other Pod'); + expect(digest.metadata.totalItems).toBe(0); + expect(digest.content).not.toMatch(/Private Elsewhere|Member Pod update/); + spy.mockRestore(); }); test('scheduler includes a member with recent pod work even after seven days away', async () => { @@ -64,7 +286,7 @@ test('scheduler includes a member with recent pod work even after seven days awa const results = await service.generateAllDailyDigests(); - expect(generate).toHaveBeenCalledWith(member._id); + expect(generate).toHaveBeenCalledWith(member._id, { requireAway: true }); expect(results).toHaveLength(2); }); diff --git a/backend/__tests__/unit/services/llmFallbacks.failClosed.test.ts b/backend/__tests__/unit/services/llmFallbacks.failClosed.test.ts index 7bd3a42659..56f8af375c 100644 --- a/backend/__tests__/unit/services/llmFallbacks.failClosed.test.ts +++ b/backend/__tests__/unit/services/llmFallbacks.failClosed.test.ts @@ -1,6 +1,7 @@ // @ts-nocheck // LLM outages must be visible to callers. These services used to return generic prose -// that looked generated even though the LLM request had failed. +// that looked generated even though the LLM request had failed. (The daily +// digest left this list on 2026-10-16: it no longer calls an LLM at all.) jest.mock('../../../models/Summary', () => ({})); jest.mock('../../../models/Post', () => ({ find: jest.fn() })); @@ -8,16 +9,12 @@ jest.mock('../../../config/db-pg', () => ({ pool: { query: jest.fn() } })); jest.mock('../../../models/Pod', () => ({})); jest.mock('../../../services/podAssetService', () => ({})); jest.mock('../../../models/User', () => ({})); -jest.mock('../../../services/digestTemplateService', () => ({ - createDigestPrompt: jest.fn(() => 'digest prompt'), -})); jest.mock('../../../services/llmService', () => ({ generateText: jest.fn() })); const { generateText } = require('../../../services/llmService'); const Post = require('../../../models/Post'); const summarizerService = require('../../../services/summarizerService'); const chatSummarizerService = require('../../../services/chatSummarizerService'); -const dailyDigestService = require('../../../services/dailyDigestService'); describe('LLM summaries fail closed', () => { beforeEach(() => { @@ -60,11 +57,4 @@ describe('LLM summaries fail closed', () => { [{ content: 'A real message', username: 'lily' }], )).rejects.toThrow('Enhanced chat summary generation returned invalid JSON'); }); - - it('does not create a generic digest after an LLM failure', async () => { - generateText.mockRejectedValueOnce(new Error('LLM unavailable')); - - await expect(dailyDigestService.generateDigestContent({}, { username: 'lily' })) - .rejects.toThrow('LLM unavailable'); - }); }); diff --git a/backend/services/dailyDigestService.ts b/backend/services/dailyDigestService.ts index 6fc4676618..53ec7ea848 100644 --- a/backend/services/dailyDigestService.ts +++ b/backend/services/dailyDigestService.ts @@ -5,55 +5,21 @@ const User = require('../models/User'); // eslint-disable-next-line global-require const Pod = require('../models/Pod'); // eslint-disable-next-line global-require -const DigestTemplateService = require('./digestTemplateService'); +const ActivityService = require('./activityService'); // eslint-disable-next-line global-require -const { generateText } = require('./llmService'); - -interface SummaryAnalytics { - quotes?: Array<{ text: string; author: string; reactions?: number }>; - insights?: Array<{ impact?: string; [key: string]: unknown }>; - timeline?: Array<{ timestamp: string; description: string }>; - atmosphere?: { - overall_sentiment?: string; - energy_level?: string; - }; -} - -interface SummaryDoc { - podId?: { name?: string; type?: string } | null; - type?: string; - content?: string; - metadata?: { - totalItems?: number; - topUsers?: string[]; - topTags?: string[]; - podName?: string; - }; - analytics?: SummaryAnalytics; -} - -interface PodData { - podType: string; - summaries: SummaryDoc[]; - totalMessages: number; - quotes: Array<{ text: string; author: string; reactions?: number }>; - insights: Array<{ impact?: string; [key: string]: unknown }>; -} - -interface OrganizedData { - byPod: Record; - timeline: Array<{ timestamp: string; description: string }>; -} - -interface CrossConversationInsights { - topUsers: string[]; - topTags: string[]; - bestQuotes: Array<{ text: string; author: string; reactions?: number }>; - keyInsights: Array<{ impact?: string; [key: string]: unknown }>; - timeline: Array<{ timestamp: string; description: string }>; - overallAtmosphere: Record; - participationOverview: Record; -} +const { resolveAgentDisplayLabel } = require('./agentIdentityService'); + +/** + * The daily digest is "what your agents did while you were away" (stickiness + * Phase 2, TASK-257; Growth spec 2026-10-15). It is built from the activity + * recap, the same read the Activity page shows, never from chat summaries: + * what is waiting on the person, what each of their agents posted, and what + * moved on the board, in the person's own pods only. + * + * Every line states something that happened, so there is no LLM step: the + * previous version asked a model to narrate chat summaries, and it could only + * ever be as true as the narration. + */ interface EmptyDigestResult { title: string; @@ -68,11 +34,121 @@ interface DigestResult { error?: string; } +interface RecapLike { + pods?: Array<{ id: string; name: string }>; + needsYou?: Array<{ title?: string; podId?: string; podName?: string }>; + agents?: Array<{ + id?: string; + name?: string; + recap?: string; + messageCount?: number; + updates?: Array<{ podId?: string | null; podName?: string; content?: string }>; + }>; + board?: Array<{ taskId?: string; title?: string; status?: string; podId?: string; podName?: string }>; +} + +/** Someone active this recently has seen it in the app; the email would repeat it. */ +export const DIGEST_AWAY_MS = 20 * 60 * 60 * 1000; +const MAX_NEEDS_YOU = 3; +const MAX_AGENTS = 5; +const MAX_BOARD = 5; +const LINE_MAX = 200; + +/** What the digest knows about an agent from its User row, keyed by user id. */ +interface AgentIdentity { + label: string; + // Scout is the guide, not one of "your agents": its welcome would otherwise + // make every new account's first digest say an agent did something. Known + // by agentName, never by display name, so a person's own agent called + // "Scout..." still counts. + guide: boolean; +} + +interface RecapContext { + publicPodIds?: Set; + agentIdentities?: Map; +} + +const oneLine = (value: unknown): string => String(value || '').replace(/\s+/g, ' ').trim().slice(0, LINE_MAX); + export class DailyDigestService { - async generateUserDailyDigest(userId: unknown): Promise { - try { - console.log(`Generating daily digest for user ${userId}`); + /** + * The digest's lines, most important first: what is waiting on the person, + * then each agent's latest work, then board changes. Empty means nothing + * happened that the person needs to hear about. + */ + static recapLines( + recap: RecapLike | null | undefined, + { publicPodIds = new Set(), agentIdentities = new Map() }: RecapContext = {}, + ): string[] { + if (!recap) return []; + const lines: string[] = []; + // recap.pods is the person's pods by Pod.members alone. An ask from a pod + // outside it (a creator who has since left, whose createdBy still names + // them) is a title from a pod they can no longer open, so it stays out. + const memberPodIds = new Set((recap.pods || []).map((pod) => String(pod.id))); + // A public pod (Commonly HQ, which every signup joins) is not "your + // agents": its agent posts and board moves are someone else's work. The + // dry run on 2026-10-16 found every outside recipient's only line was a + // support answer to another person in HQ. What waits on YOU stays, wherever. + // A line with no pod (a feed lookup that missed) has unknown provenance, so + // it stays out too: this guard withholds other people's content. + const inPrivatePod = (podId: unknown) => Boolean(podId) && !publicPodIds.has(String(podId)); + const asks = (recap.needsYou || []).filter((item) => memberPodIds.has(String(item.podId))); + asks.slice(0, MAX_NEEDS_YOU).forEach((item) => { + const title = oneLine(item.title); + if (title) lines.push(`Waiting on you: ${title}${item.podName ? ` (${oneLine(item.podName)})` : ''}`); + }); + (recap.agents || []) + .map((agent) => ({ agent, identity: agentIdentities.get(String(agent.id)) })) + .filter(({ identity }) => !identity?.guide) + .map(({ agent, identity }) => ({ + agent, + identity, + updates: (agent.updates || []).filter((update) => inPrivatePod(update.podId)), + })) + .filter(({ updates }) => updates.length > 0) + .slice(0, MAX_AGENTS) + .forEach(({ agent, identity, updates }) => { + const name = oneLine(identity?.label || agent.name) || 'An agent'; + const [latest] = updates; + // Counted from the updates kept, so a public pod's posts never inflate it. + const more = updates.length > 1 ? ` (+${updates.length - 1} more)` : ''; + lines.push(`${name} in ${oneLine(latest.podName) || 'a pod'}: ${oneLine(latest.content)}${more}`); + }); + (recap.board || []).filter((task) => inPrivatePod(task.podId)).slice(0, MAX_BOARD).forEach((task) => { + const title = oneLine(task.title); + if (!title) return; + const id = task.taskId ? `${task.taskId} ` : ''; + const pod = task.podName ? ` (${oneLine(task.podName)})` : ''; + lines.push(`${id}${title} is ${oneLine(task.status) || 'updated'}${pod}`); + }); + return lines; + } + + /** Each recap agent's display label and whether it is the guide, from its User row. */ + static async agentIdentities(recap: RecapLike | null): Promise> { + const ids = (recap?.agents || []).map((agent) => String(agent.id || '')).filter((id) => /^[a-f0-9]{24}$/i.test(id)); + if (ids.length === 0) return new Map(); + const users = await User.find({ _id: { $in: ids } }).select('username botMetadata').lean() as Array<{ + _id: unknown; username?: string; botMetadata?: { displayName?: string; instanceId?: string; agentName?: string }; + }>; + return new Map(users.map((user) => [String(user._id), { + label: resolveAgentDisplayLabel(user, user.username), + guide: String(user.botMetadata?.agentName || '').toLowerCase() === 'scout', + }])); + } + /** + * One person's digest for the last 24 hours. The scheduled run passes + * requireAway, so someone active in the last DIGEST_AWAY_MS gets an empty + * digest (they have seen it in the app); an on-demand generation does not. + */ + async generateUserDailyDigest( + userId: unknown, + { requireAway = false }: { requireAway?: boolean } = {}, + ): Promise { + try { const user = await User.findById(userId).lean() as Record | null; if (!user) { throw new Error(`User ${userId} not found`); @@ -80,25 +156,23 @@ export class DailyDigestService { const endTime = new Date(); const startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000); + const lastActive = user.lastActive ? new Date(user.lastActive as string).getTime() : 0; + const away = !lastActive || endTime.getTime() - lastActive >= DIGEST_AWAY_MS; + + // The recap reads current pod membership itself (Pod.members). + const recap: RecapLike | null = (requireAway && !away) + ? null + : await ActivityService.getRecap(userId, { window: 'today' }); + const publicPods = recap + ? await Pod.find({ members: userId, publicRead: true }).select('_id').lean() as Array<{ _id: unknown }> + : []; + const lines = DailyDigestService.recapLines(recap, { + publicPodIds: new Set(publicPods.map((pod) => String(pod._id))), + agentIdentities: await DailyDigestService.agentIdentities(recap), + }); + const podCount = recap?.pods?.length || 0; - // Pod.members is the current access relationship; current pod joins do - // not populate the legacy User.subscribedPods field. - const pods = await Pod.find({ members: userId }).select('_id').lean() as Array<{ _id: unknown }>; - const podIds = pods.map((pod) => pod._id); - - const summaries = await Summary.find({ - $or: [ - { podId: { $in: podIds } }, - { type: 'posts' }, - { type: 'chats', podId: { $exists: false } }, - ], - createdAt: { $gte: startTime, $lte: endTime }, - }) - .populate('podId', 'name type') - .sort({ createdAt: 1 }) - .lean() as SummaryDoc[]; - - if (summaries.length === 0) { + if (lines.length === 0) { const emptyData = DailyDigestService.createEmptyDigest( user as { username: string }, startTime, @@ -113,222 +187,33 @@ export class DailyDigestService { totalItems: 0, topTags: [], topUsers: [], - subscribedPods: podIds.length, + subscribedPods: podCount, userId: String(userId), }, analytics: emptyData.analytics, }); } - const organizedData = DailyDigestService.organizeSummariesForDigest(summaries); - const digestContent = await this.generateDigestContent(organizedData, user as { username: string }); - const insights = DailyDigestService.extractCrossConversationInsights(summaries); - - const digestSummary = await Summary.create({ + return Summary.create({ type: 'daily-digest', - title: `Daily Digest for ${user.username} - ${endTime.toDateString()}`, - content: digestContent, + title: requireAway ? 'What your agents did while you were away' : 'What your agents did today', + content: lines.map((line) => `- ${line}`).join('\n'), timeRange: { start: startTime, end: endTime }, metadata: { - totalItems: summaries.length, - topTags: insights.topTags, - topUsers: insights.topUsers, - subscribedPods: podIds.length, + totalItems: lines.length, + topTags: [], + topUsers: [], + subscribedPods: podCount, userId: String(userId), }, - analytics: { - timeline: insights.timeline, - quotes: insights.bestQuotes, - insights: insights.keyInsights, - atmosphere: insights.overallAtmosphere, - participation: insights.participationOverview, - }, + analytics: DailyDigestService.createEmptyDigest(user as { username: string }, startTime, endTime).analytics, }); - - console.log(`✓ Generated daily digest for ${user.username}`); - return digestSummary; } catch (error) { console.error(`Error generating daily digest for user ${userId}:`, error); throw error; } } - static organizeSummariesForDigest(summaries: SummaryDoc[]): OrganizedData { - const byPod: Record = {}; - const timeline: Array<{ timestamp: string; description: string }> = []; - - summaries.forEach((summary) => { - const podName = (summary.podId as { name?: string } | null)?.name || 'General'; - const podType = (summary.podId as { type?: string } | null)?.type || summary.type || 'general'; - - if (!byPod[podName]) { - byPod[podName] = { - podType, - summaries: [], - totalMessages: 0, - quotes: [], - insights: [], - }; - } - - byPod[podName].summaries.push(summary); - byPod[podName].totalMessages += summary.metadata?.totalItems || 0; - - if (summary.analytics) { - byPod[podName].quotes.push(...(summary.analytics.quotes || [])); - byPod[podName].insights.push(...(summary.analytics.insights || [])); - timeline.push(...(summary.analytics.timeline || [])); - } - }); - - return { - byPod, - timeline: timeline.sort( - (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), - ), - }; - } - - async generateDigestContent(organizedData: OrganizedData, user: { username: string }): Promise { - const prompt = DigestTemplateService.createDigestPrompt(organizedData, user); - - try { - return await generateText(prompt, { temperature: 0.4 }) as string; - } catch (error) { - console.error('Error generating digest content with AI:', error); - throw error; - } - } - - static createDigestPrompt(organizedData: OrganizedData, user: { username: string }): string { - const { byPod, timeline } = organizedData; - - let podSummaries = ''; - Object.entries(byPod).forEach(([podName, data]) => { - podSummaries += `\n## ${podName} (${data.podType})\n`; - podSummaries += `Messages: ${data.totalMessages}\n`; - data.summaries.forEach((summary) => { - podSummaries += `- ${summary.content}\n`; - }); - - if (data.quotes.length > 0) { - podSummaries += 'Notable quotes:\n'; - data.quotes.slice(0, 3).forEach((quote) => { - podSummaries += ` "${quote.text}" - ${quote.author}\n`; - }); - } - }); - - return `Create a personalized daily digest newsletter for ${user.username}. -You are writing a friendly, engaging daily summary that feels like a thoughtful friend catching them up on what happened in their communities. - -Community Activity Summary: -${podSummaries} - -Timeline of Events: -${timeline - .slice(0, 10) - .map((event) => `${event.timestamp}: ${event.description}`) - .join('\n')} - -Create a well-structured daily digest that includes: - -1. **Opening**: Warm, personalized greeting -2. **Highlights**: Top 3-5 most interesting developments -3. **Community Pulse**: Overall mood and energy across their subscribed communities -4. **Notable Moments**: Best quotes, insights, or funny moments -5. **Looking Ahead**: Any ongoing discussions or trends to watch - -Make it engaging, informative, and personal. Use markdown formatting for structure. Keep it concise but comprehensive - aim for a 2-3 minute read.`; - } - - static extractCrossConversationInsights(summaries: SummaryDoc[]): CrossConversationInsights { - const allQuotes: Array<{ text: string; author: string; reactions?: number }> = []; - const allInsights: Array<{ impact?: string; [key: string]: unknown }> = []; - const allTimeline: Array<{ timestamp: string; description: string }> = []; - const allUsers: string[] = []; - const allTags: string[] = []; - - let totalMessages = 0; - const sentimentScores: number[] = []; - const energyLevels: number[] = []; - - summaries.forEach((summary) => { - totalMessages += summary.metadata?.totalItems || 0; - - if (summary.metadata?.topUsers) { - allUsers.push(...summary.metadata.topUsers); - } - - if (summary.metadata?.topTags) { - allTags.push(...summary.metadata.topTags); - } - - if (summary.analytics) { - allQuotes.push(...(summary.analytics.quotes || [])); - allInsights.push(...(summary.analytics.insights || [])); - allTimeline.push(...(summary.analytics.timeline || [])); - - if (summary.analytics.atmosphere) { - const atm = summary.analytics.atmosphere; - sentimentScores.push(DailyDigestService.sentimentToScore(atm.overall_sentiment)); - energyLevels.push(DailyDigestService.energyToScore(atm.energy_level)); - } - } - }); - - const topUsers = DailyDigestService.getTopItems(allUsers, 5); - const topTags = DailyDigestService.getTopItems(allTags, 8); - const bestQuotes = allQuotes - .sort((a, b) => (b.reactions || 0) - (a.reactions || 0)) - .slice(0, 5); - - const impactScore: Record = { high: 3, medium: 2, low: 1 }; - const keyInsights = allInsights - .sort((a, b) => (impactScore[String(b.impact || '')] || 0) - (impactScore[String(a.impact || '')] || 0)) - .slice(0, 8); - - const timeline = allTimeline - .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) - .slice(0, 10); - - const engagementQuality = (() => { - if (totalMessages > 100) return 'intense'; - if (totalMessages > 50) return 'deep'; - if (totalMessages > 20) return 'moderate'; - return 'superficial'; - })(); - - return { - topUsers, - topTags, - bestQuotes, - keyInsights, - timeline, - overallAtmosphere: { - overall_sentiment: DailyDigestService.scoreToSentiment(DailyDigestService.average(sentimentScores)), - energy_level: DailyDigestService.scoreToEnergy(DailyDigestService.average(energyLevels)), - engagement_quality: engagementQuality, - community_cohesion: Math.min(topUsers.length / 10, 1), - topics_diversity: Math.min(topTags.length / 15, 1), - dominant_emotions: ['engagement', 'community'], - }, - participationOverview: { - most_active_users: topUsers.slice(0, 5).map((u) => ({ - username: u, - message_count: 0, - engagement_score: 0.8, - role: 'contributor', - })), - engagement_patterns: { - peak_hours: [], - discussion_length_avg: summaries.length ? totalMessages / summaries.length : 0, - response_time_avg: 5, - }, - }, - }; - } - static createEmptyDigest(user: { username: string }, startTime: Date, endTime: Date): EmptyDigestResult { return { title: `Daily Digest for ${user.username} - ${endTime.toDateString()}`, @@ -375,7 +260,7 @@ This might be a great time to start a new conversation or share something intere createdAt: { $gte: startTime }, }); // Public-read pods include every signup; their activity is not a reason - // to wake every stale account for an LLM-generated digest. + // to wake every stale account. const recentMemberIds = await Pod.distinct('members', { _id: { $in: recentPodIds }, publicRead: { $ne: true }, @@ -394,7 +279,7 @@ This might be a great time to start a new conversation or share something intere const results = await Promise.allSettled( activeUsers.map(async (user) => { try { - const digest = await this.generateUserDailyDigest(user._id); + const digest = await this.generateUserDailyDigest(user._id, { requireAway: true }); return { userId: user._id, success: true, digest }; } catch (error) { console.error(`Failed to generate digest for user ${user._id}:`, error); @@ -413,58 +298,12 @@ This might be a great time to start a new conversation or share something intere } } - static getTopItems(items: string[], limit: number): string[] { - const counts: Record = {}; - items.forEach((item) => { - counts[item] = (counts[item] || 0) + 1; - }); - return Object.entries(counts) - .sort(([, a], [, b]) => b - a) - .slice(0, limit) - .map(([item]) => item); - } - - static sentimentToScore(sentiment?: string): number { - const scores: Record = { - very_negative: 1, negative: 2, neutral: 3, positive: 4, very_positive: 5, - }; - return scores[sentiment || ''] || 3; - } - - static energyToScore(energy?: string): number { - const scores: Record = { - very_low: 1, low: 2, medium: 3, high: 4, very_high: 5, - }; - return scores[energy || ''] || 3; - } - - static scoreToSentiment(score: number): string { - if (score >= 4.5) return 'very_positive'; - if (score >= 3.5) return 'positive'; - if (score >= 2.5) return 'neutral'; - if (score >= 1.5) return 'negative'; - return 'very_negative'; - } - - static scoreToEnergy(score: number): string { - if (score >= 4.5) return 'very_high'; - if (score >= 3.5) return 'high'; - if (score >= 2.5) return 'medium'; - if (score >= 1.5) return 'low'; - return 'very_low'; - } - - static average(arr: number[]): number { - return arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 3; - } - static getTimeOfDayGreeting(): string { const hour = new Date().getHours(); if (hour < 12) return 'morning'; if (hour < 17) return 'afternoon'; return 'evening'; } - } export default new DailyDigestService(); diff --git a/backend/services/digestTemplateService.ts b/backend/services/digestTemplateService.ts deleted file mode 100644 index 01fb61b0ca..0000000000 --- a/backend/services/digestTemplateService.ts +++ /dev/null @@ -1,245 +0,0 @@ -interface UserLike { - username: string; -} - -interface AtmosphereLike { - overall_sentiment?: string; - energy_level?: string; - engagement_quality?: string; -} - -interface InsightsLike { - totalItems?: number; - overallAtmosphere?: AtmosphereLike; - topUsers?: unknown[]; - bestQuotes?: Array<{ text: string; author: string; context: string }>; - keyInsights?: Array<{ type: string; description: string }>; - timeline?: Array<{ timestamp: string; description: string }>; -} - -interface MetadataLike { - subscribedPods?: number; -} - -class DigestTemplateService { - static createDigestPrompt(organizedData: unknown, user: UserLike): string { - const { username } = user; - const date = new Date().toDateString(); - - return `Create a personalized daily digest newsletter for ${username} using this EXACT format and structure: - -COMMUNITY DATA: -${JSON.stringify(organizedData, null, 2)} - -REQUIRED FORMAT (follow this structure exactly): - -# 🌅 Daily Digest - ${date} - -Good morning, ${username}! - -Ready for your daily dose of community insights? Let's dive into what's been happening while you were away. - -## ✨ Today's Highlights - -[List 3-5 most significant developments, each as a bullet point with descriptive titles] - -## 💬 Notable Moments - -### 🔥 Quote of the Day -> "[Most engaging or insightful quote from discussions]" -> -> *— @[username] in [community/context]* - -### 🎯 Key Insights -- **[Insight Type]**: [Brief description of trend, consensus, or development] -- **[Community Vibe]**: [Description of overall mood and energy] - -## 📊 Community Pulse - -- **Overall Mood**: [Emoji] [Sentiment description] -- **Energy Level**: [Emoji] [Activity level description] -- **Engagement Quality**: [Emoji] [Type of discussions happening] -- **Active Communities**: [Number] of your subscribed communities had activity - -## 🔮 Looking Ahead - -[2-3 sentences about trends, upcoming discussions, or community direction] - ---- -*Your personalized digest • Generated with ❤️ by Commonly AI* - -GUIDELINES: -- Use engaging, friendly tone -- Include specific details from the data -- Personalize content for ${username} -- Use emojis strategically for visual appeal -- Keep sections concise but informative -- Focus on community connection and engagement -- If data is limited, acknowledge it positively -- Highlight ${username}'s contributions when present`; - } - - static createAnalyticsPrompt(messages: unknown, podName: string): string { - return `Analyze this chat data and extract structured analytics for community insights. - -CHAT DATA: -Pod: ${podName} -Messages: ${JSON.stringify(messages, null, 2)} - -Extract and return ONLY a valid JSON object with this structure: -{ - "timeline": [ - { - "timestamp": "ISO date", - "event": "topic_shift|peak_activity|heated_discussion|new_participant|milestone", - "description": "Brief description of what happened", - "participants": ["username1", "username2"], - "intensity": 1-10 - } - ], - "quotes": [ - { - "text": "Exact quote text", - "author": "username", - "timestamp": "ISO date", - "context": "What was being discussed", - "sentiment": "positive|negative|neutral|humorous|insightful", - "reactions": 0 - } - ], - "insights": [ - { - "type": "trend|sentiment_shift|new_topic|consensus|disagreement|revelation", - "description": "What insight was gained", - "confidence": 0.0-1.0, - "impact": "low|medium|high", - "participants": ["username1"], - "timestamp": "ISO date" - } - ], - "atmosphere": { - "overall_sentiment": "very_positive|positive|neutral|negative|very_negative", - "energy_level": "very_low|low|medium|high|very_high", - "engagement_quality": "superficial|moderate|deep|intense", - "community_cohesion": 0.0-1.0, - "topics_diversity": 0.0-1.0, - "dominant_emotions": ["emotion1", "emotion2"] - }, - "participation": { - "most_active_users": [ - { - "username": "username", - "message_count": 0, - "engagement_score": 0.0-1.0, - "role": "moderator|contributor|lurker|newcomer" - } - ], - "engagement_patterns": { - "peak_hours": [14, 15, 16], - "discussion_length_avg": 0, - "response_time_avg": 0 - } - } -} - -IMPORTANT: Return ONLY the JSON object, no additional text or explanation.`; - } - - static getPersonalizedGreeting(user: UserLike, timeOfDay: string, activityLevel: string): string { - const { username } = user; - - const greetings: Record> = { - morning: { - high: `Rise and shine, ${username}! Your communities were buzzing overnight.`, - medium: `Good morning, ${username}! Here's what happened while you were away.`, - low: `Morning, ${username}! It was a quiet night, but there are still some gems to discover.`, - }, - afternoon: { - high: `Good afternoon, ${username}! Catching up on a busy day in your communities?`, - medium: `Hey ${username}! Ready for your daily community roundup?`, - low: `Hi ${username}! Not much happened, but here's what's worth knowing.`, - }, - evening: { - high: `Evening, ${username}! Your communities had quite the active day.`, - medium: `Good evening, ${username}! Time to catch up on today's highlights.`, - low: `Evening, ${username}! A peaceful day in your communities, but still some nice moments to share.`, - }, - }; - - return ( - greetings[timeOfDay]?.[activityLevel] - || `Hello, ${username}! Here's your daily community digest.` - ); - } - - static buildConditionalSections(insights: InsightsLike, _user: UserLike): string { - const sections: string[] = []; - - if (insights.bestQuotes && insights.bestQuotes.length > 0) { - const topQuote = insights.bestQuotes[0]; - sections.push(`### 🔥 Quote of the Day -> "${topQuote.text}" -> -> *— @${topQuote.author} in ${topQuote.context}*`); - } - - if (insights.keyInsights && insights.keyInsights.length > 0) { - let insightText = '### 🎯 Key Insights\n'; - insights.keyInsights.slice(0, 3).forEach((insight) => { - insightText += `- **${insight.type.replace('_', ' ').toUpperCase()}**: ${insight.description}\n`; - }); - sections.push(insightText.trim()); - } - - if (insights.timeline && insights.timeline.length > 0) { - let timelineText = '### ⏰ Timeline Highlights\n'; - insights.timeline.slice(0, 3).forEach((event) => { - const time = new Date(event.timestamp).toLocaleTimeString('en-US', { - hour: 'numeric', - minute: '2-digit', - }); - timelineText += `- **${time}**: ${event.description}\n`; - }); - sections.push(timelineText.trim()); - } - - return sections.join('\n\n'); - } - - static formatCommunityPulse(atmosphere: AtmosphereLike | null, metadata: MetadataLike | null): string { - const moodEmojis: Record = { - very_positive: '🌟', - positive: '😊', - neutral: '😐', - negative: '😔', - very_negative: '😞', - }; - - const energyEmojis: Record = { - very_high: '🚀', - high: '⚡', - medium: '🔋', - low: '🔅', - very_low: '💤', - }; - - const engagementEmojis: Record = { - intense: '🔥', - deep: '🎯', - moderate: '💬', - superficial: '👋', - }; - - return `## 📊 Community Pulse - -- **Overall Mood**: ${moodEmojis[atmosphere?.overall_sentiment || ''] || '😊'} ${atmosphere?.overall_sentiment?.replace('_', ' ') || 'Positive'} -- **Energy Level**: ${energyEmojis[atmosphere?.energy_level || ''] || '🔋'} ${atmosphere?.energy_level?.replace('_', ' ') || 'Moderate'} -- **Engagement Quality**: ${engagementEmojis[atmosphere?.engagement_quality || ''] || '💬'} ${atmosphere?.engagement_quality || 'Good'} discussions -- **Active Communities**: ${metadata?.subscribedPods || 0} of your subscribed communities had activity`; - } -} - -export default DigestTemplateService; -// CJS compat: let require() return the default export directly -// eslint-disable-next-line @typescript-eslint/no-require-imports -module.exports = exports["default"]; Object.assign(module.exports, exports);