From 2be0cafe97a62b57b8ff9b09e80ebcac8700efac Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 1 Jan 2026 15:19:09 +0200 Subject: [PATCH 1/5] feat(meeting-briefing): refactor to agentic approach with research tools --- .../web/__tests__/ai-meeting-briefing.test.ts | 492 ++++++++++++++++++ .../ai/meeting-briefs/generate-briefing.ts | 134 +++-- .../utils/meeting-briefs/gather-context.ts | 35 +- apps/web/utils/meeting-briefs/process.ts | 1 + 4 files changed, 585 insertions(+), 77 deletions(-) create mode 100644 apps/web/__tests__/ai-meeting-briefing.test.ts diff --git a/apps/web/__tests__/ai-meeting-briefing.test.ts b/apps/web/__tests__/ai-meeting-briefing.test.ts new file mode 100644 index 0000000000..6d6a2c43e0 --- /dev/null +++ b/apps/web/__tests__/ai-meeting-briefing.test.ts @@ -0,0 +1,492 @@ +import { describe, expect, test, vi } from "vitest"; +import { + aiGenerateMeetingBriefing, + buildPrompt, + formatMeetingForContext, + type BriefingContent, +} from "@/utils/ai/meeting-briefs/generate-briefing"; +import type { MeetingBriefingData } from "@/utils/meeting-briefs/gather-context"; +import type { CalendarEvent } from "@/utils/calendar/event-types"; +import { getEmailAccount, getMockMessage } from "@/__tests__/helpers"; +import { createScopedLogger } from "@/utils/logger"; + +// pnpm test-ai ai-meeting-briefing + +const isAiTest = process.env.RUN_AI_TESTS === "true"; + +vi.mock("server-only", () => ({})); + +const logger = createScopedLogger("ai-meeting-briefing-test"); + +const TIMEOUT = 60_000; // Longer timeout for agentic flow with research + +function getCalendarEvent( + overrides: Partial = {}, +): CalendarEvent { + return { + id: "event-1", + title: "Product Discussion", + description: "Discuss Q1 roadmap and upcoming features", + startTime: new Date("2025-02-01T10:00:00Z"), + endTime: new Date("2025-02-01T11:00:00Z"), + attendees: [ + { email: "user@test.com", name: "Test User" }, + { email: "alice@external.com", name: "Alice External" }, + ], + ...overrides, + }; +} + +function getMeetingBriefingData( + overrides: Partial = {}, +): MeetingBriefingData { + return { + event: getCalendarEvent(), + externalGuests: [{ email: "alice@external.com", name: "Alice External" }], + emailThreads: [], + pastMeetings: [], + ...overrides, + }; +} + +describe("buildPrompt", () => { + test("builds prompt with meeting title and description", () => { + const data = getMeetingBriefingData(); + const prompt = buildPrompt(data, "America/New_York"); + + expect(prompt).toContain("Product Discussion"); + expect(prompt).toContain("Q1 roadmap"); + }); + + test("includes guest email and name in context", () => { + const data = getMeetingBriefingData({ + externalGuests: [{ email: "bob@company.com", name: "Bob Smith" }], + }); + const prompt = buildPrompt(data, null); + + expect(prompt).toContain("bob@company.com"); + expect(prompt).toContain("Bob Smith"); + }); + + test("includes no_prior_context tag for guests without history", () => { + const data = getMeetingBriefingData({ + externalGuests: [{ email: "new@contact.com", name: "New Contact" }], + emailThreads: [], + pastMeetings: [], + }); + const prompt = buildPrompt(data, null); + + expect(prompt).toContain(""); + expect(prompt).toContain("new contact"); + }); + + test("includes recent emails for guest with email history", () => { + const mockMessage = getMockMessage({ + from: "alice@external.com", + to: "user@test.com", + subject: "Re: Partnership proposal", + textPlain: "Looking forward to discussing the partnership.", + }); + + const data = getMeetingBriefingData({ + externalGuests: [{ email: "alice@external.com", name: "Alice External" }], + emailThreads: [ + { + id: "thread-1", + snippet: "Looking forward to discussing the partnership.", + messages: [mockMessage], + }, + ], + }); + const prompt = buildPrompt(data, null); + + expect(prompt).toContain(""); + expect(prompt).toContain("Partnership proposal"); + }); + + test("includes past meetings for guest with meeting history", () => { + const pastMeeting: CalendarEvent = { + id: "past-event-1", + title: "Initial Discussion", + startTime: new Date("2025-01-15T14:00:00Z"), + endTime: new Date("2025-01-15T15:00:00Z"), + attendees: [ + { email: "user@test.com" }, + { email: "alice@external.com", name: "Alice External" }, + ], + }; + + const data = getMeetingBriefingData({ + externalGuests: [{ email: "alice@external.com", name: "Alice External" }], + pastMeetings: [pastMeeting], + }); + const prompt = buildPrompt(data, "America/New_York"); + + expect(prompt).toContain(""); + expect(prompt).toContain("Initial Discussion"); + }); + + test("handles multiple guests correctly", () => { + const data = getMeetingBriefingData({ + externalGuests: [ + { email: "alice@acme.com", name: "Alice Smith" }, + { email: "bob@acme.com", name: "Bob Jones" }, + ], + }); + const prompt = buildPrompt(data, null); + + expect(prompt).toContain("alice@acme.com"); + expect(prompt).toContain("Alice Smith"); + expect(prompt).toContain("bob@acme.com"); + expect(prompt).toContain("Bob Jones"); + }); +}); + +describe("formatMeetingForContext", () => { + test("formats meeting with title and date", () => { + const meeting: CalendarEvent = { + id: "meeting-1", + title: "Weekly Sync", + startTime: new Date("2025-01-20T09:00:00Z"), + endTime: new Date("2025-01-20T10:00:00Z"), + attendees: [], + }; + const result = formatMeetingForContext(meeting, "America/New_York"); + + expect(result).toContain(""); + expect(result).toContain("Weekly Sync"); + expect(result).toContain(""); + }); + + test("includes description when present", () => { + const meeting: CalendarEvent = { + id: "meeting-1", + title: "Strategy Meeting", + description: "Review Q2 strategy and goals", + startTime: new Date("2025-01-20T09:00:00Z"), + endTime: new Date("2025-01-20T10:00:00Z"), + attendees: [], + }; + const result = formatMeetingForContext(meeting, null); + + expect(result).toContain("Q2 strategy"); + }); + + test("truncates long descriptions", () => { + const longDescription = "A".repeat(600); + const meeting: CalendarEvent = { + id: "meeting-1", + title: "Meeting", + description: longDescription, + startTime: new Date("2025-01-20T09:00:00Z"), + endTime: new Date("2025-01-20T10:00:00Z"), + attendees: [], + }; + const result = formatMeetingForContext(meeting, null); + + // Description should be truncated to 500 chars + expect(result.length).toBeLessThan(longDescription.length); + }); +}); + +describe.runIf(isAiTest)( + "aiGenerateMeetingBriefing", + () => { + test("generates briefing for single guest with no prior context", async () => { + // Add minimal email context so test doesn't rely solely on research API + const mockMessage = getMockMessage({ + from: "new.person@example.com", + to: "user@test.com", + subject: "Looking forward to our coffee chat", + textPlain: + "Hi! Excited to meet tomorrow. I work as a product manager at TechCo.", + }); + + const data = getMeetingBriefingData({ + event: getCalendarEvent({ + title: "Coffee Chat", + description: "Casual catch-up", + }), + externalGuests: [ + { email: "new.person@example.com", name: "New Person" }, + ], + emailThreads: [ + { + id: "thread-1", + snippet: "Looking forward to our coffee chat", + messages: [mockMessage], + }, + ], + pastMeetings: [], + }); + + const result = await aiGenerateMeetingBriefing({ + briefingData: data, + emailAccount: getEmailAccount(), + logger, + }); + + prettyPrintBriefing(result, data.event.title); + + expect(result.guests).toHaveLength(1); + expect(result.guests[0].email).toBe("new.person@example.com"); + expect(result.guests[0].bullets).toBeDefined(); + expect(result.guests[0].bullets.length).toBeGreaterThan(0); + }); + + test("generates briefing for guest with email history", async () => { + const mockMessage = getMockMessage({ + from: "partner@startup.io", + to: "user@test.com", + subject: "Partnership Proposal", + textPlain: + "Hi, we'd like to discuss a potential partnership between our companies. We specialize in AI automation tools.", + }); + + const data = getMeetingBriefingData({ + event: getCalendarEvent({ + title: "Partnership Discussion", + description: "Follow up on partnership proposal", + attendees: [ + { email: "user@test.com" }, + { email: "partner@startup.io", name: "Partner Person" }, + ], + }), + externalGuests: [ + { email: "partner@startup.io", name: "Partner Person" }, + ], + emailThreads: [ + { + id: "thread-1", + snippet: "Partnership proposal", + messages: [mockMessage], + }, + ], + pastMeetings: [], + }); + + const result = await aiGenerateMeetingBriefing({ + briefingData: data, + emailAccount: getEmailAccount(), + logger, + }); + + prettyPrintBriefing(result, data.event.title); + + expect(result.guests).toHaveLength(1); + expect(result.guests[0].email).toBe("partner@startup.io"); + // Should reference partnership or the email context + const bulletText = result.guests[0].bullets.join(" ").toLowerCase(); + expect( + bulletText.includes("partnership") || + bulletText.includes("ai") || + bulletText.includes("automation"), + ).toBe(true); + }); + + test("generates briefing for multiple guests from same company", async () => { + const data = getMeetingBriefingData({ + event: getCalendarEvent({ + title: "Team Sync with Acme Corp", + description: "Quarterly review with Acme team", + attendees: [ + { email: "user@test.com" }, + { email: "alice@acme.com", name: "Alice CEO" }, + { email: "bob@acme.com", name: "Bob CTO" }, + ], + }), + externalGuests: [ + { email: "alice@acme.com", name: "Alice CEO" }, + { email: "bob@acme.com", name: "Bob CTO" }, + ], + emailThreads: [], + pastMeetings: [], + }); + + const result = await aiGenerateMeetingBriefing({ + briefingData: data, + emailAccount: getEmailAccount(), + logger, + }); + + prettyPrintBriefing(result, data.event.title); + + expect(result.guests).toHaveLength(2); + + const guestEmails = result.guests.map((g) => g.email); + expect(guestEmails).toContain("alice@acme.com"); + expect(guestEmails).toContain("bob@acme.com"); + + // Each guest should have bullets + for (const guest of result.guests) { + expect(guest.bullets.length).toBeGreaterThan(0); + } + }); + + test("generates briefing with past meeting context", async () => { + const pastMeeting: CalendarEvent = { + id: "past-1", + title: "Initial Product Demo", + description: "Showed the main features of our platform", + startTime: new Date("2025-01-10T15:00:00Z"), + endTime: new Date("2025-01-10T16:00:00Z"), + attendees: [ + { email: "user@test.com" }, + { email: "prospect@bigcorp.com", name: "Prospect Lead" }, + ], + }; + + // Add email context so test doesn't rely solely on research API + const mockMessage = getMockMessage({ + from: "prospect@bigcorp.com", + to: "user@test.com", + subject: "Thanks for the demo!", + textPlain: + "Great demo yesterday. Looking forward to seeing the enterprise features.", + }); + + const data = getMeetingBriefingData({ + event: getCalendarEvent({ + title: "Follow-up Demo", + description: "Deep dive into enterprise features", + attendees: [ + { email: "user@test.com" }, + { email: "prospect@bigcorp.com", name: "Prospect Lead" }, + ], + }), + externalGuests: [ + { email: "prospect@bigcorp.com", name: "Prospect Lead" }, + ], + emailThreads: [ + { + id: "thread-1", + snippet: "Thanks for the demo!", + messages: [mockMessage], + }, + ], + pastMeetings: [pastMeeting], + }); + + const result = await aiGenerateMeetingBriefing({ + briefingData: data, + emailAccount: getEmailAccount(), + logger, + }); + + prettyPrintBriefing(result, data.event.title); + + expect(result.guests).toHaveLength(1); + expect(result.guests[0].email).toBe("prospect@bigcorp.com"); + + // Should reference the past meeting or demo + const bulletText = result.guests[0].bullets.join(" ").toLowerCase(); + expect( + bulletText.includes("demo") || + bulletText.includes("product") || + bulletText.includes("meeting") || + bulletText.includes("previous") || + bulletText.includes("enterprise"), + ).toBe(true); + }); + + test("returns empty guests array when no external guests", async () => { + const data = getMeetingBriefingData({ + externalGuests: [], + }); + + const result = await aiGenerateMeetingBriefing({ + briefingData: data, + emailAccount: getEmailAccount(), + logger, + }); + + prettyPrintBriefing(result, data.event.title); + + expect(result.guests).toHaveLength(0); + }); + + test("shows full briefing bits that will be used in the email", async () => { + // Create rich context to see a realistic briefing + const mockMessage1 = getMockMessage({ + from: "ceo@techstartup.io", + to: "user@test.com", + subject: "Partnership Discussion", + textPlain: + "Hi! Following up on our call. We're excited about integrating your AI features into our platform. Our team of 50 engineers is ready to start. Let me know about enterprise pricing.", + }); + + const mockMessage2 = getMockMessage({ + id: "msg2", + from: "user@test.com", + to: "ceo@techstartup.io", + subject: "Re: Partnership Discussion", + textPlain: + "Thanks for reaching out! Happy to discuss enterprise options. Looking forward to our meeting.", + }); + + const pastMeeting: CalendarEvent = { + id: "past-1", + title: "Intro Call with TechStartup", + description: "Initial discovery call", + startTime: new Date("2025-01-15T10:00:00Z"), + endTime: new Date("2025-01-15T10:30:00Z"), + attendees: [ + { email: "user@test.com" }, + { email: "ceo@techstartup.io", name: "Alex Chen" }, + ], + }; + + const data = getMeetingBriefingData({ + event: getCalendarEvent({ + title: "Partnership Deep Dive with TechStartup", + description: "Discuss enterprise pricing and integration timeline", + attendees: [ + { email: "user@test.com", name: "Test User" }, + { email: "ceo@techstartup.io", name: "Alex Chen" }, + ], + }), + externalGuests: [{ email: "ceo@techstartup.io", name: "Alex Chen" }], + emailThreads: [ + { + id: "thread-1", + snippet: "Partnership Discussion", + messages: [mockMessage1, mockMessage2], + }, + ], + pastMeetings: [pastMeeting], + }); + + // Generate the briefing + const result = await aiGenerateMeetingBriefing({ + briefingData: data, + emailAccount: getEmailAccount(), + logger, + }); + + prettyPrintBriefing(result, data.event.title); + + expect(result.guests.length).toBeGreaterThan(0); + }); + }, + TIMEOUT, +); + +function prettyPrintBriefing(result: BriefingContent, meetingTitle: string) { + console.log(`\n${"=".repeat(80)}`); + console.log("BRIEFING OUTPUT (The bits for the email):"); + console.log("=".repeat(80)); + console.log(JSON.stringify(result, null, 2)); + + console.log(`\n${"=".repeat(80)}`); + console.log("HUMAN READABLE VIEW:"); + console.log("=".repeat(80)); + console.log(`Meeting: ${meetingTitle}`); + console.log("\nGuests:"); + for (const guest of result.guests) { + console.log(`\n ${guest.name} (${guest.email})`); + for (const bullet of guest.bullets) { + console.log(` - ${bullet}`); + } + } + console.log(`${"=".repeat(80)}\n`); +} diff --git a/apps/web/utils/ai/meeting-briefs/generate-briefing.ts b/apps/web/utils/ai/meeting-briefs/generate-briefing.ts index 2b730f3b1f..0694e63707 100644 --- a/apps/web/utils/ai/meeting-briefs/generate-briefing.ts +++ b/apps/web/utils/ai/meeting-briefs/generate-briefing.ts @@ -1,6 +1,7 @@ +import { tool } from "ai"; import { z } from "zod"; import { getModel } from "@/utils/llms/model"; -import { createGenerateObject } from "@/utils/llms"; +import { createGenerateText } from "@/utils/llms"; import type { EmailAccountWithAI } from "@/utils/llms/types"; import type { CalendarEvent } from "@/utils/calendar/event-types"; import type { MeetingBriefingData } from "@/utils/meeting-briefs/gather-context"; @@ -8,6 +9,8 @@ import { stringifyEmailSimple } from "@/utils/stringify-email"; import { getEmailForLLM } from "@/utils/get-email-from-message"; import type { ParsedMessage } from "@/utils/types"; import { formatDateTimeInUserTimezone } from "@/utils/date"; +import { researchGuestWithPerplexity } from "@/utils/ai/meeting-briefs/research-guest"; +import type { Logger } from "@/utils/logger"; const guestBriefingSchema = z.object({ name: z.string().describe("The guest's name"), @@ -22,55 +25,107 @@ const briefingSchema = z.object({ .array(guestBriefingSchema) .describe("Briefing information for each meeting guest"), }); -type BriefingContent = z.infer; +export type BriefingContent = z.infer; + +const AGENTIC_SYSTEM_PROMPT = `You are an AI assistant that prepares concise meeting briefings. + +Your task is to prepare a briefing about the external guests the user is meeting with. + +WORKFLOW: +1. Review the provided context (email history, past meetings) for each guest +2. For each guest, use the researchGuest tool to find their LinkedIn profile, current role, company, and background +3. Once you have gathered all information, call finalizeBriefing with the complete briefing + +TOOLS AVAILABLE: +- researchGuest: Research a guest's professional background (LinkedIn, role, company, work history) +- finalizeBriefing: Submit the final briefing (MUST be called when done) + +BRIEFING GUIDELINES: +- Keep it concise: <10 bullet points per guest, max 10 words per bullet +- Focus on what's helpful before the meeting: role, company, recent discussions, pending items +- Don't repeat meeting details (time, date, location) - the user already has those +- If a guest has no prior context and research returns nothing useful, note they are a new contact (one bullet only) +- ONLY include information about the specific guests listed. Do NOT mention other attendees or colleagues. +- Research may be inaccurate for common names - note any uncertainty + +IMPORTANT: You MUST call finalizeBriefing when you are done to submit your briefing. If research fails or returns nothing, still call finalizeBriefing with the information you have.`; export async function aiGenerateMeetingBriefing({ briefingData, emailAccount, + logger, }: { briefingData: MeetingBriefingData; emailAccount: EmailAccountWithAI; + logger: Logger; }): Promise { - const system = `You are an AI assistant that prepares concise meeting briefings. - -Your task is to prepare a briefing that includes: -(1) Key details about the external guests the user is meeting with -(2) Any relevant context from past email exchanges and meetings with them -(3) AI-researched background information (LinkedIn, current role, company, work history) when available - -Guidelines: -- Keep it short and use <10 bullets per meeting guest (max 10 words per bullet) -- Don't include details about the meeting itself (time, date, location, etc.) - the user already has that -- Focus on information that would be helpful to know before the meeting -- Include any recent topics discussed, pending items, or relationship context -- When AI research is available (LinkedIn, role, company), include it to help the user understand who they're meeting -- If a guest has , simply note they are a new contact (one bullet point only, don't repeat this in multiple ways) -- ONLY include information about the specific guests listed in . Do NOT mention other meeting attendees, organizers, or colleagues. -- AI research may be inaccurate for common names or generic email addresses - -Return a structured JSON object with a "guests" array. Each guest should have: -- "name": The guest's display name -- "email": The guest's email address -- "bullets": An array of brief bullet points about them (max 10 words each)`; + if (briefingData.externalGuests.length === 0) { + return { guests: [] }; + } const prompt = buildPrompt(briefingData, emailAccount.timezone); - const modelOptions = getModel(emailAccount.user); - const generateObject = createGenerateObject({ + const generateText = createGenerateText({ emailAccount, label: "Meeting Briefing", modelOptions, }); - const result = await generateObject({ + let result: BriefingContent | null = null; + + await generateText({ ...modelOptions, - system, + system: AGENTIC_SYSTEM_PROMPT, prompt, - schema: briefingSchema, + stopWhen: (stepResult) => + stepResult.steps.some((step) => + step.toolCalls?.some((call) => call.toolName === "finalizeBriefing"), + ) || stepResult.steps.length > 15, + tools: { + researchGuest: tool({ + description: + "Research a meeting guest to find their LinkedIn profile, current role, company, and professional background", + inputSchema: z.object({ + email: z.string().describe("The guest's email address"), + name: z.string().optional().describe("The guest's name if known"), + }), + execute: async ({ email, name }) => { + logger.info("Researching guest", { email, name }); + const research = await researchGuestWithPerplexity({ + email, + name, + event: briefingData.event, + emailAccount, + logger, + }); + if (!research) { + return { found: false, message: "No research results available" }; + } + return { found: true, research }; + }, + }), + finalizeBriefing: tool({ + description: + "Submit the final meeting briefing. Call this when you have gathered all information about all guests.", + inputSchema: briefingSchema, + execute: async (briefing) => { + logger.info("Finalizing briefing", { + guestCount: briefing.guests.length, + }); + result = briefing; + return { success: true }; + }, + }), + }, }); - return result.object; + if (!result) { + logger.warn("No briefing result captured, returning empty briefing"); + return { guests: [] }; + } + + return result; } // Exported for testing @@ -86,14 +141,13 @@ export function buildPrompt( (guest) => ({ email: guest.email, name: guest.name, - aiResearch: guest.aiResearch ?? undefined, recentEmails: selectRecentEmailsForGuest(allMessages, guest.email), recentMeetings: selectRecentMeetingsForGuest(pastMeetings, guest.email), timezone, }), ); - const prompt = `Please prepare a concise briefing for this meeting. + const prompt = `Prepare a concise briefing for this upcoming meeting. Title: ${event.title} @@ -104,7 +158,10 @@ ${event.description ? `Description: ${event.description}` : ""} ${guestContexts.map((guest) => formatGuestContext(guest)).join("\n")} -Return the briefing as a JSON object with a "guests" array containing structured information for each guest.`; +For each guest listed above: +1. Review their email and meeting history provided +2. Use the researchGuest tool to find their professional background +3. Once you have all information, call finalizeBriefing with the complete briefing`; return prompt; } @@ -114,38 +171,29 @@ type GuestContextForPrompt = { name?: string; recentEmails: ParsedMessage[]; recentMeetings: CalendarEvent[]; - aiResearch?: string; timezone: string | null; }; function formatGuestContext(guest: GuestContextForPrompt): string { const recentEmails = guest.recentEmails ?? []; const recentMeetings = guest.recentMeetings ?? []; - const aiResearch = guest.aiResearch; - const hasAiResearch = Boolean(aiResearch); const hasEmails = recentEmails.length > 0; const hasMeetings = recentMeetings.length > 0; const guestHeader = `${guest.name ? `Name: ${guest.name}\n` : ""}Email: ${guest.email}`; - if (!hasAiResearch && !hasEmails && !hasMeetings) { + if (!hasEmails && !hasMeetings) { return ` ${guestHeader} -This appears to be a new contact with no prior email, meeting, or public profile history. +This appears to be a new contact with no prior email or meeting history. Use the researchGuest tool to find information about them. `; } const sections: string[] = []; - if (hasAiResearch) { - sections.push(` -${aiResearch} -`); - } - if (hasEmails) { sections.push(` ${recentEmails diff --git a/apps/web/utils/meeting-briefs/gather-context.ts b/apps/web/utils/meeting-briefs/gather-context.ts index 5207ecc032..9f2cc78dd6 100644 --- a/apps/web/utils/meeting-briefs/gather-context.ts +++ b/apps/web/utils/meeting-briefs/gather-context.ts @@ -9,9 +9,6 @@ import type { CalendarEventProvider, } from "@/utils/calendar/event-types"; import { extractDomainFromEmail } from "@/utils/email"; -import { researchGuestWithPerplexity } from "@/utils/ai/meeting-briefs/research-guest"; -import { getEmailAccountWithAi } from "@/utils/user/get"; -import { SafeError } from "@/utils/error"; const MAX_THREADS = 10; const MAX_MESSAGES_PER_THREAD = 10; @@ -24,7 +21,6 @@ export type { CalendarEvent, CalendarEventAttendee }; export interface ExternalGuest { email: string; name?: string; - aiResearch?: string | null; } export interface MeetingBriefingData { @@ -84,45 +80,16 @@ export async function gatherContextForEvent({ messages: thread.messages.slice(-MAX_MESSAGES_PER_THREAD), })); - const emailAccount = await getEmailAccountWithAi({ - emailAccountId, - }); - - if (!emailAccount) { - logger.error("Email account not found"); - throw new SafeError("Email account not found"); - } - - const guestResearchPromises = externalAttendees.map((attendee) => - researchGuestWithPerplexity({ - event, - name: attendee.name, - email: attendee.email, - emailAccount, - logger, - }).catch((error) => { - logger.warn("Failed to research guest", { - email: attendee.email, - error, - }); - return null; - }), - ); - - const aiResearchResults = await Promise.all(guestResearchPromises); - logger.info("Gathered context for meeting", { threadCount: cappedThreads.length, meetingCount: pastMeetings.length, - researchedGuests: aiResearchResults.filter((c) => c !== null).length, }); return { event, - externalGuests: externalAttendees.map((a, index) => ({ + externalGuests: externalAttendees.map((a) => ({ email: a.email, name: a.name, - aiResearch: aiResearchResults[index] ?? null, })), emailThreads: cappedThreads, pastMeetings, diff --git a/apps/web/utils/meeting-briefs/process.ts b/apps/web/utils/meeting-briefs/process.ts index 07217ce738..cea6574a1a 100644 --- a/apps/web/utils/meeting-briefs/process.ts +++ b/apps/web/utils/meeting-briefs/process.ts @@ -203,6 +203,7 @@ export async function runMeetingBrief({ const briefingContent = await aiGenerateMeetingBriefing({ briefingData, emailAccount, + logger: eventLog, }); await sendBriefingEmail({ From 2de7d01d7b5005356def77c8012fc158c24ebf0d Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 1 Jan 2026 15:26:06 +0200 Subject: [PATCH 2/5] fixes --- .../web/__tests__/ai-meeting-briefing.test.ts | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/web/__tests__/ai-meeting-briefing.test.ts b/apps/web/__tests__/ai-meeting-briefing.test.ts index 6d6a2c43e0..9658830f6b 100644 --- a/apps/web/__tests__/ai-meeting-briefing.test.ts +++ b/apps/web/__tests__/ai-meeting-briefing.test.ts @@ -77,7 +77,7 @@ describe("buildPrompt", () => { const prompt = buildPrompt(data, null); expect(prompt).toContain(""); - expect(prompt).toContain("new contact"); + expect(prompt).toContain("New Contact"); }); test("includes recent emails for guest with email history", () => { @@ -472,21 +472,21 @@ describe.runIf(isAiTest)( ); function prettyPrintBriefing(result: BriefingContent, meetingTitle: string) { - console.log(`\n${"=".repeat(80)}`); - console.log("BRIEFING OUTPUT (The bits for the email):"); - console.log("=".repeat(80)); - console.log(JSON.stringify(result, null, 2)); - - console.log(`\n${"=".repeat(80)}`); - console.log("HUMAN READABLE VIEW:"); - console.log("=".repeat(80)); - console.log(`Meeting: ${meetingTitle}`); - console.log("\nGuests:"); + console.debug(`\n${"=".repeat(80)}`); + console.debug("BRIEFING OUTPUT (The bits for the email):"); + console.debug("=".repeat(80)); + console.debug(JSON.stringify(result, null, 2)); + + console.debug(`\n${"=".repeat(80)}`); + console.debug("HUMAN READABLE VIEW:"); + console.debug("=".repeat(80)); + console.debug(`Meeting: ${meetingTitle}`); + console.debug("\nGuests:"); for (const guest of result.guests) { - console.log(`\n ${guest.name} (${guest.email})`); + console.debug(`\n ${guest.name} (${guest.email})`); for (const bullet of guest.bullets) { - console.log(` - ${bullet}`); + console.debug(` - ${bullet}`); } } - console.log(`${"=".repeat(80)}\n`); + console.debug(`${"=".repeat(80)}\n`); } From 6ff89ca1e2bda3dbfc8fe2b3eb12ae372dbe6639 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 1 Jan 2026 16:02:39 +0200 Subject: [PATCH 3/5] adjust how we do research for meeting brief agent. simplify into a single agent --- .../ai/meeting-briefs/generate-briefing.ts | 319 +++++++++++++++--- .../utils/ai/meeting-briefs/research-guest.ts | 144 -------- apps/web/utils/email.ts | 1 + ...rplexity-research.ts => research-cache.ts} | 49 ++- apps/web/utils/user/delete.ts | 6 +- packages/resend/emails/meeting-briefing.tsx | 9 +- 6 files changed, 316 insertions(+), 212 deletions(-) delete mode 100644 apps/web/utils/ai/meeting-briefs/research-guest.ts rename apps/web/utils/redis/{perplexity-research.ts => research-cache.ts} (60%) diff --git a/apps/web/utils/ai/meeting-briefs/generate-briefing.ts b/apps/web/utils/ai/meeting-briefs/generate-briefing.ts index 0694e63707..3eb8342dba 100644 --- a/apps/web/utils/ai/meeting-briefs/generate-briefing.ts +++ b/apps/web/utils/ai/meeting-briefs/generate-briefing.ts @@ -1,5 +1,9 @@ -import { tool } from "ai"; +import { tool, type ToolSet } from "ai"; import { z } from "zod"; +import { createPerplexity } from "@ai-sdk/perplexity"; +import { openai } from "@ai-sdk/openai"; +import { google } from "@ai-sdk/google"; +import { env } from "@/env"; import { getModel } from "@/utils/llms/model"; import { createGenerateText } from "@/utils/llms"; import type { EmailAccountWithAI } from "@/utils/llms/types"; @@ -9,8 +13,17 @@ import { stringifyEmailSimple } from "@/utils/stringify-email"; import { getEmailForLLM } from "@/utils/get-email-from-message"; import type { ParsedMessage } from "@/utils/types"; import { formatDateTimeInUserTimezone } from "@/utils/date"; -import { researchGuestWithPerplexity } from "@/utils/ai/meeting-briefs/research-guest"; +import { + getCachedResearch, + setCachedResearch, +} from "@/utils/redis/research-cache"; import type { Logger } from "@/utils/logger"; +import { Provider } from "@/utils/llms/config"; + +const MAX_AGENT_STEPS = 15; +const MAX_EMAILS_PER_GUEST = 10; +const MAX_MEETINGS_PER_GUEST = 10; +const MAX_DESCRIPTION_LENGTH = 500; const guestBriefingSchema = z.object({ name: z.string().describe("The guest's name"), @@ -33,12 +46,15 @@ Your task is to prepare a briefing about the external guests the user is meeting WORKFLOW: 1. Review the provided context (email history, past meetings) for each guest -2. For each guest, use the researchGuest tool to find their LinkedIn profile, current role, company, and background -3. Once you have gathered all information, call finalizeBriefing with the complete briefing +2. Use search tools to research each guest's professional background +3. Once you have gathered all information, call finalizeBriefing -TOOLS AVAILABLE: -- researchGuest: Research a guest's professional background (LinkedIn, role, company, work history) -- finalizeBriefing: Submit the final briefing (MUST be called when done) +SEARCH TIPS: +- Use the guest's email domain to identify their company (e.g., john@acme.com likely works at Acme) +- Include company name in searches to disambiguate common names +- Look for LinkedIn profiles, current role, and company info +- If results seem uncertain (common name, conflicting info), note that in the briefing +- You can try multiple search tools if one doesn't return good results BRIEFING GUIDELINES: - Keep it concise: <10 bullet points per guest, max 10 words per bullet @@ -46,9 +62,15 @@ BRIEFING GUIDELINES: - Don't repeat meeting details (time, date, location) - the user already has those - If a guest has no prior context and research returns nothing useful, note they are a new contact (one bullet only) - ONLY include information about the specific guests listed. Do NOT mention other attendees or colleagues. -- Research may be inaccurate for common names - note any uncertainty +- Note any uncertainty about identity (common names, conflicting info) + +IMPORTANT: You MUST call finalizeBriefing when you are done to submit your briefing.`; -IMPORTANT: You MUST call finalizeBriefing when you are done to submit your briefing. If research fails or returns nothing, still call finalizeBriefing with the information you have.`; +const searchInputSchema = z.object({ + query: z.string().describe("The search query"), + email: z.string().describe("The guest's email address (used for caching)"), + name: z.string().optional().describe("The guest's name if known"), +}); export async function aiGenerateMeetingBriefing({ briefingData, @@ -63,6 +85,17 @@ export async function aiGenerateMeetingBriefing({ return { guests: [] }; } + // Build tools based on what's configured + const tools = buildSearchTools({ emailAccount, logger }); + + // If no search tools are available, skip the agentic approach + if (Object.keys(tools).length === 0) { + logger.info( + "No search tools configured, skipping agentic briefing generation", + ); + return generateFallbackBriefing(briefingData.externalGuests); + } + const prompt = buildPrompt(briefingData, emailAccount.timezone); const modelOptions = getModel(emailAccount.user); @@ -81,30 +114,9 @@ export async function aiGenerateMeetingBriefing({ stopWhen: (stepResult) => stepResult.steps.some((step) => step.toolCalls?.some((call) => call.toolName === "finalizeBriefing"), - ) || stepResult.steps.length > 15, + ) || stepResult.steps.length > MAX_AGENT_STEPS, tools: { - researchGuest: tool({ - description: - "Research a meeting guest to find their LinkedIn profile, current role, company, and professional background", - inputSchema: z.object({ - email: z.string().describe("The guest's email address"), - name: z.string().optional().describe("The guest's name if known"), - }), - execute: async ({ email, name }) => { - logger.info("Researching guest", { email, name }); - const research = await researchGuestWithPerplexity({ - email, - name, - event: briefingData.event, - emailAccount, - logger, - }); - if (!research) { - return { found: false, message: "No research results available" }; - } - return { found: true, research }; - }, - }), + ...tools, finalizeBriefing: tool({ description: "Submit the final meeting briefing. Call this when you have gathered all information about all guests.", @@ -121,13 +133,214 @@ export async function aiGenerateMeetingBriefing({ }); if (!result) { - logger.warn("No briefing result captured, returning empty briefing"); - return { guests: [] }; + logger.warn( + "Agent did not finalize briefing, generating fallback from guest list", + ); + return generateFallbackBriefing(briefingData.externalGuests); } return result; } +function generateFallbackBriefing( + guests: { email: string; name?: string }[], +): BriefingContent { + return { + guests: guests.map((guest) => ({ + name: guest.name || guest.email.split("@")[0], + email: guest.email, + bullets: ["Research incomplete - meeting guest"], + })), + }; +} + +function buildSearchTools({ + emailAccount, + logger, +}: { + emailAccount: EmailAccountWithAI; + logger: Logger; +}): ToolSet { + const tools: ToolSet = {}; + + // Perplexity search (if configured) + if (env.PERPLEXITY_API_KEY) { + tools.perplexitySearch = tool({ + description: "Search for information using Perplexity", + inputSchema: searchInputSchema, + execute: async ({ query, email, name }) => { + logger.info("Perplexity search", { query, email, name }); + + const cached = await getCachedResearch( + emailAccount.userId, + "perplexity", + email, + name, + ); + if (cached) { + logger.info("Using cached Perplexity result", { email }); + return cached; + } + + try { + const perplexity = createPerplexity({ + apiKey: env.PERPLEXITY_API_KEY, + }); + + const perplexityGenerateText = createGenerateText({ + emailAccount, + label: "Perplexity Search", + modelOptions: { + modelName: "sonar-pro", + model: perplexity("sonar-pro"), + provider: "perplexity", + backupModel: null, + }, + }); + + const searchResult = await perplexityGenerateText({ + model: perplexity("sonar-pro"), + prompt: query, + }); + + const text = searchResult.text; + + setCachedResearch( + emailAccount.userId, + "perplexity", + email, + name, + text, + ).catch((error) => { + logger.error("Failed to cache Perplexity result", { error }); + }); + + return text; + } catch (error) { + logger.error("Perplexity search failed", { error, query }); + return "Search failed. Try another search tool."; + } + }, + }); + } + + // Web search (OpenAI, Google, or OpenRouter - if configured) + const webSearchConfig = getWebSearchConfig(); + if (webSearchConfig) { + tools.webSearch = createWebSearchTool({ + emailAccount, + logger, + providerName: webSearchConfig.providerName, + getSearchTools: webSearchConfig.getSearchTools, + useOnlineVariant: webSearchConfig.useOnlineVariant, + }); + } + + return tools; +} + +type WebSearchConfig = { + providerName: string; + useOnlineVariant: boolean; + getSearchTools?: () => ToolSet; +}; + +function getWebSearchConfig(): WebSearchConfig | null { + switch (env.DEFAULT_LLM_PROVIDER) { + case Provider.OPEN_AI: + return { + providerName: "OpenAI", + useOnlineVariant: false, + getSearchTools: () => ({ web_search: openai.tools.webSearch({}) }), + }; + case Provider.GOOGLE: + return { + providerName: "Google", + useOnlineVariant: false, + getSearchTools: () => ({ + google_search: google.tools.googleSearch({}), + }), + }; + case Provider.OPENROUTER: + return { + providerName: "OpenRouter", + useOnlineVariant: true, + }; + default: + return null; + } +} + +function createWebSearchTool({ + emailAccount, + logger, + providerName, + getSearchTools, + useOnlineVariant, +}: { + emailAccount: EmailAccountWithAI; + logger: Logger; + providerName: string; + getSearchTools?: () => ToolSet; + useOnlineVariant: boolean; +}) { + return tool({ + description: "Search the web for information", + inputSchema: searchInputSchema, + execute: async ({ query, email, name }) => { + logger.info(`Web search (${providerName})`, { query, email, name }); + + const cached = await getCachedResearch( + emailAccount.userId, + "websearch", + email, + name, + ); + if (cached) { + logger.info("Using cached web search result", { email }); + return cached; + } + + try { + const modelOptions = getModel( + emailAccount.user, + "economy", + useOnlineVariant, + ); + + const webGenerateText = createGenerateText({ + emailAccount, + label: "Web Search", + modelOptions, + }); + + const searchResult = await webGenerateText({ + model: modelOptions.model, + prompt: query, + ...(getSearchTools && { tools: getSearchTools() }), + }); + + const text = searchResult.text; + + setCachedResearch( + emailAccount.userId, + "websearch", + email, + name, + text, + ).catch((error) => { + logger.error("Failed to cache web search result", { error }); + }); + + return text; + } catch (error) { + logger.error("Web search failed", { error, query }); + return "Search failed. Try another search tool."; + } + }, + }); +} + // Exported for testing export function buildPrompt( briefingData: MeetingBriefingData, @@ -147,6 +360,22 @@ export function buildPrompt( }), ); + // List available search tools for the prompt + const availableTools: string[] = []; + if (env.PERPLEXITY_API_KEY) availableTools.push("perplexitySearch"); + if ( + env.DEFAULT_LLM_PROVIDER === Provider.OPEN_AI || + env.DEFAULT_LLM_PROVIDER === Provider.GOOGLE || + env.DEFAULT_LLM_PROVIDER === Provider.OPENROUTER + ) { + availableTools.push("webSearch"); + } + + const toolsNote = + availableTools.length > 0 + ? `\nAvailable search tools: ${availableTools.join(", ")}` + : ""; + const prompt = `Prepare a concise briefing for this upcoming meeting. @@ -157,10 +386,11 @@ ${event.description ? `Description: ${event.description}` : ""} ${guestContexts.map((guest) => formatGuestContext(guest)).join("\n")} +${toolsNote} For each guest listed above: 1. Review their email and meeting history provided -2. Use the researchGuest tool to find their professional background +2. Use search tools to find their professional background 3. Once you have all information, call finalizeBriefing with the complete briefing`; return prompt; @@ -175,11 +405,8 @@ type GuestContextForPrompt = { }; function formatGuestContext(guest: GuestContextForPrompt): string { - const recentEmails = guest.recentEmails ?? []; - const recentMeetings = guest.recentMeetings ?? []; - - const hasEmails = recentEmails.length > 0; - const hasMeetings = recentMeetings.length > 0; + const hasEmails = guest.recentEmails.length > 0; + const hasMeetings = guest.recentMeetings.length > 0; const guestHeader = `${guest.name ? `Name: ${guest.name}\n` : ""}Email: ${guest.email}`; @@ -187,7 +414,7 @@ function formatGuestContext(guest: GuestContextForPrompt): string { return ` ${guestHeader} -This appears to be a new contact with no prior email or meeting history. Use the researchGuest tool to find information about them. +This appears to be a new contact with no prior email or meeting history. Use search tools to find information about them. `; } @@ -196,7 +423,7 @@ ${guestHeader} if (hasEmails) { sections.push(` -${recentEmails +${guest.recentEmails .map( (email) => `\n${stringifyEmailSimple(getEmailForLLM(email))}\n`, @@ -207,7 +434,7 @@ ${recentEmails if (hasMeetings) { sections.push(` -${recentMeetings.map((meeting) => formatMeetingForContext(meeting, guest.timezone)).join("\n")} +${guest.recentMeetings.map((meeting) => formatMeetingForContext(meeting, guest.timezone)).join("\n")} `); } @@ -228,7 +455,7 @@ function selectRecentMeetingsForGuest( return pastMeetings .filter((m) => m.attendees.some((a) => a.email.toLowerCase() === email)) .sort((a, b) => b.startTime.getTime() - a.startTime.getTime()) - .slice(0, 10); + .slice(0, MAX_MEETINGS_PER_GUEST); } function selectRecentEmailsForGuest( @@ -240,7 +467,7 @@ function selectRecentEmailsForGuest( return messages .filter((m) => messageIncludesEmail(m, email)) .sort((a, b) => getMessageTimestampMs(b) - getMessageTimestampMs(a)) - .slice(0, 10); + .slice(0, MAX_EMAILS_PER_GUEST); } function messageIncludesEmail( @@ -276,7 +503,7 @@ export function formatMeetingForContext( return ` Title: ${meeting.title} Date: ${dateStr} -${meeting.description ? `Description: ${meeting.description.slice(0, 500)}` : ""} +${meeting.description ? `Description: ${meeting.description.slice(0, MAX_DESCRIPTION_LENGTH)}` : ""} `; } diff --git a/apps/web/utils/ai/meeting-briefs/research-guest.ts b/apps/web/utils/ai/meeting-briefs/research-guest.ts deleted file mode 100644 index 9079dec0e7..0000000000 --- a/apps/web/utils/ai/meeting-briefs/research-guest.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { createPerplexity } from "@ai-sdk/perplexity"; -import { openai } from "@ai-sdk/openai"; -import { google } from "@ai-sdk/google"; -import type { ToolSet } from "ai"; -import { env } from "@/env"; -import { createGenerateText } from "@/utils/llms"; -import type { Logger } from "@/utils/logger"; -import type { EmailAccountWithAI, UserAIFields } from "@/utils/llms/types"; -import { - getCachedPerplexityResearch, - setCachedPerplexityResearch, -} from "@/utils/redis/perplexity-research"; -import type { CalendarEvent } from "@/utils/calendar/event-types"; -import { getModel, type SelectModel } from "@/utils/llms/model"; -import { Provider } from "@/utils/llms/config"; - -export async function researchGuestWithPerplexity({ - name, - email, - event, - emailAccount, - logger, -}: { - name?: string; - email: string; - event: CalendarEvent | null; - emailAccount: EmailAccountWithAI; - logger: Logger; -}): Promise { - const modelOptions = getLlmModel(emailAccount.user); - - if (!modelOptions) { - logger.warn("No LLM model available for guest research, skipping research"); - return null; - } - - const cached = await getCachedPerplexityResearch( - emailAccount.userId, - email, - name, - ); - if (cached) { - logger.info("Using cached Perplexity research"); - return cached; - } - - try { - const prompt = `${ - name - ? `Research ${name}.` - : `First, identify the person's name from the meeting details below or derive it from their email address. Then search for them by name.` - } -Their email is: ${email}. - -Upcoming meeting details to help you research the guest: - -${ - event - ? ` -${event.title} -${event.description} -${event.location} -` - : "" -} - -Tell me about what they do, their current role, company, and work history. -Include any relevant profile URLs you find (LinkedIn, company page, personal site, etc). - -IMPORTANT: Report back all searches you made in order to come up with the information you provided me.`; - - const generateText = createGenerateText({ - emailAccount, - label: "Guest Research", - modelOptions, - }); - - const result = await generateText({ - prompt, - model: modelOptions.model, - tools: modelOptions.tools, - }); - - // Fire-and-forget: cache write should never block or lose the result - setCachedPerplexityResearch( - emailAccount.userId, - email, - name, - result.text, - ).catch((error) => { - logger.error("Failed to cache Perplexity research", { error }); - }); - - return result.text; - } catch (error) { - logger.error("Failed to research guest with Perplexity", { error }); - return null; - } -} - -function getLlmModel( - userAi: UserAIFields, -): (SelectModel & { tools: ToolSet }) | null { - if (env.PERPLEXITY_API_KEY) { - const perplexityProvider = createPerplexity({ - apiKey: env.PERPLEXITY_API_KEY, - }); - - const modelName = "sonar-pro"; - const model = perplexityProvider(modelName); - - return { - modelName, - model, - provider: "perplexity", - backupModel: null, - tools: {}, - }; - } - - if (env.DEFAULT_LLM_PROVIDER === Provider.OPENROUTER) { - return { ...getModel(userAi, "economy", true), tools: {} }; - } - - if (env.DEFAULT_LLM_PROVIDER === Provider.OPEN_AI) { - return { - ...getModel(userAi, "economy"), - tools: { - web_search: openai.tools.webSearch({}), - }, - }; - } - - if (env.DEFAULT_LLM_PROVIDER === Provider.GOOGLE) { - return { - ...getModel(userAi, "economy"), - tools: { - google_search: google.tools.googleSearch({}), - }, - }; - } - - return null; -} diff --git a/apps/web/utils/email.ts b/apps/web/utils/email.ts index 3979113716..d218b6e24a 100644 --- a/apps/web/utils/email.ts +++ b/apps/web/utils/email.ts @@ -142,6 +142,7 @@ export const PUBLIC_EMAIL_DOMAINS = new Set([ "fastmail.com", "gmx.com", "hey.com", + "mail.com", ]); // Returns the search term to use when checking for previous communications diff --git a/apps/web/utils/redis/perplexity-research.ts b/apps/web/utils/redis/research-cache.ts similarity index 60% rename from apps/web/utils/redis/perplexity-research.ts rename to apps/web/utils/redis/research-cache.ts index 7277e4670d..c5ba37d5b4 100644 --- a/apps/web/utils/redis/perplexity-research.ts +++ b/apps/web/utils/redis/research-cache.ts @@ -3,18 +3,21 @@ import { env } from "@/env"; import { createScopedLogger } from "@/utils/logger"; import { redis } from "@/utils/redis"; -const logger = createScopedLogger("redis/perplexity-research"); +const logger = createScopedLogger("redis/research-cache"); -const CACHE_KEY_PREFIX = "perplexity-research"; +const CACHE_KEY_PREFIX = "research"; const CACHE_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days const MAX_CONTENT_SIZE = 1024 * 1024; // 1MB +export type ResearchSource = "perplexity" | "websearch"; + function isRedisConfigured(): boolean { return Boolean(env.UPSTASH_REDIS_URL && env.UPSTASH_REDIS_TOKEN); } -function getPerplexityResearchKey( +function getResearchCacheKey( userId: string, + source: ResearchSource, email: string, name: string | undefined, ) { @@ -22,23 +25,28 @@ function getPerplexityResearchKey( const normalizedName = name?.trim().toLowerCase() ?? ""; const input = `${normalizedEmail}:${normalizedName}`; const hash = createHash("sha256").update(input).digest("hex"); - return `${CACHE_KEY_PREFIX}:${userId}:${hash}`; + return `${CACHE_KEY_PREFIX}:${source}:${userId}:${hash}`; } -function getUserKeyPattern(userId: string) { - return `${CACHE_KEY_PREFIX}:${userId}:*`; +function getUserKeyPattern(userId: string, source?: ResearchSource) { + if (source) { + return `${CACHE_KEY_PREFIX}:${source}:${userId}:*`; + } + // Match all sources for this user + return `${CACHE_KEY_PREFIX}:*:${userId}:*`; } -export async function clearCachedPerplexityResearchForUser( +export async function clearCachedResearchForUser( userId: string, + source?: ResearchSource, ): Promise { if (!isRedisConfigured()) return 0; - const pattern = getUserKeyPattern(userId); + const pattern = getUserKeyPattern(userId, source); let deletedCount = 0; - let cursor = 0; try { + let cursor = 0; do { const [nextCursor, keys] = await redis.scan(cursor, { match: pattern, @@ -53,24 +61,27 @@ export async function clearCachedPerplexityResearchForUser( } while (cursor !== 0); if (deletedCount > 0) { - logger.info("Cleared cached perplexity research for user", { + logger.info("Cleared cached research for user", { userId, + source: source ?? "all", deletedCount, }); } return deletedCount; } catch (error) { - logger.error("Failed to clear cached perplexity research for user", { + logger.error("Failed to clear cached research for user", { userId, + source: source ?? "all", error, }); return deletedCount; } } -export async function getCachedPerplexityResearch( +export async function getCachedResearch( userId: string, + source: ResearchSource, email: string, name: string | undefined, ): Promise { @@ -78,16 +89,17 @@ export async function getCachedPerplexityResearch( try { return await redis.get( - getPerplexityResearchKey(userId, email, name), + getResearchCacheKey(userId, source, email, name), ); } catch (error) { - logger.error("Failed to get cached perplexity research", { email, error }); + logger.error("Failed to get cached research", { source, email, error }); return null; } } -export async function setCachedPerplexityResearch( +export async function setCachedResearch( userId: string, + source: ResearchSource, email: string, name: string | undefined, content: string, @@ -95,11 +107,12 @@ export async function setCachedPerplexityResearch( if (!isRedisConfigured()) return; if (!content?.trim()) { - logger.warn("Skipping cache: content is empty", { email }); + logger.warn("Skipping cache: content is empty", { source, email }); return; } if (content.length > MAX_CONTENT_SIZE) { logger.warn("Skipping cache: content exceeds max size", { + source, email, size: content.length, maxSize: MAX_CONTENT_SIZE, @@ -108,9 +121,9 @@ export async function setCachedPerplexityResearch( } try { - const key = getPerplexityResearchKey(userId, email, name); + const key = getResearchCacheKey(userId, source, email, name); await redis.set(key, content, { ex: CACHE_TTL_SECONDS }); } catch (error) { - logger.error("Failed to cache perplexity research", { email, error }); + logger.error("Failed to cache research", { source, email, error }); } } diff --git a/apps/web/utils/user/delete.ts b/apps/web/utils/user/delete.ts index f4b5ea3c8c..7cc46e37f2 100644 --- a/apps/web/utils/user/delete.ts +++ b/apps/web/utils/user/delete.ts @@ -9,7 +9,7 @@ import { createEmailProvider } from "@/utils/email/provider"; import type { EmailProvider } from "@/utils/email/types"; import type { Logger } from "@/utils/logger"; import { sleep } from "@/utils/sleep"; -import { clearCachedPerplexityResearchForUser } from "@/utils/redis/perplexity-research"; +import { clearCachedResearchForUser } from "@/utils/redis/research-cache"; export async function deleteUser({ userId, @@ -68,8 +68,8 @@ export async function deleteUser({ captureException(error); }); - clearCachedPerplexityResearchForUser(userId).catch((error) => { - logger.error("Error clearing cached Perplexity research", { error }); + clearCachedResearchForUser(userId).catch((error) => { + logger.error("Error clearing cached research", { error }); captureException(error); }); diff --git a/packages/resend/emails/meeting-briefing.tsx b/packages/resend/emails/meeting-briefing.tsx index c3c39ce9da..bece701ac1 100644 --- a/packages/resend/emails/meeting-briefing.tsx +++ b/packages/resend/emails/meeting-briefing.tsx @@ -97,10 +97,17 @@ export default function MeetingBriefingEmail({ )} -
+
{renderGuestBriefings(briefingContent.guests)}
+
+ + Note: This briefing is AI-generated and may be inaccurate, + especially for common names. + +
+
From bf92508c2ff6a4eb72660734df6c09f840b730aa Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 1 Jan 2026 16:55:38 +0200 Subject: [PATCH 4/5] fix: prevent double draft emails when custom rule and TO_REPLY both have DRAFT_EMAIL Moves limitDraftEmailActions to execute AFTER conversation meta-rules are resolved, so it can see all DRAFT_EMAIL actions and properly limit to one draft. --- .../utils/ai/choose-rule/run-rules.test.ts | 248 +++++++++++++++++- apps/web/utils/ai/choose-rule/run-rules.ts | 84 +++--- 2 files changed, 291 insertions(+), 41 deletions(-) diff --git a/apps/web/utils/ai/choose-rule/run-rules.test.ts b/apps/web/utils/ai/choose-rule/run-rules.test.ts index 59399590ad..67e30b5ec3 100644 --- a/apps/web/utils/ai/choose-rule/run-rules.test.ts +++ b/apps/web/utils/ai/choose-rule/run-rules.test.ts @@ -3,6 +3,7 @@ import { ensureConversationRuleContinuity, CONVERSATION_TRACKING_META_RULE_ID, limitDraftEmailActions, + runRules, } from "./run-rules"; import { ActionType, @@ -13,13 +14,37 @@ import type { Action } from "@/generated/prisma/client"; import { ConditionType } from "@/utils/config"; import prisma from "@/utils/__mocks__/prisma"; import type { RuleWithActions } from "@/utils/types"; -import { getAction } from "@/__tests__/helpers"; +import { getAction, getEmailAccount, getEmail } from "@/__tests__/helpers"; import { createScopedLogger } from "@/utils/logger"; const logger = createScopedLogger("test"); vi.mock("@/utils/prisma"); vi.mock("server-only", () => ({})); +vi.mock("next/server", () => ({ after: vi.fn((fn) => fn()) })); +vi.mock("@/utils/ai/choose-rule/match-rules", () => ({ + findMatchingRules: vi.fn(), +})); +vi.mock("@/utils/reply-tracker/handle-conversation-status", () => ({ + determineConversationStatus: vi.fn(), + updateThreadTrackers: vi.fn(), +})); +vi.mock("@/utils/ai/choose-rule/choose-args", () => ({ + getActionItemsWithAiArgs: vi.fn(), +})); +vi.mock("@/utils/ai/choose-rule/execute", () => ({ + executeAct: vi.fn(), +})); +vi.mock("@/utils/reply-tracker/label-helpers", () => ({ + removeConflictingThreadStatusLabels: vi.fn(), +})); +vi.mock("@/utils/cold-email/is-cold-email", () => ({ + saveColdEmail: vi.fn(), +})); +vi.mock("@/utils/scheduled-actions/scheduler", () => ({ + scheduleDelayedActions: vi.fn(), + cancelScheduledActions: vi.fn(), +})); const emailAccountId = "account-1"; const threadId = "thread-1"; @@ -424,9 +449,228 @@ describe("limitDraftEmailActions", () => { const result = limitDraftEmailActions(matches, logger); - // Should select draft-2 because it has fixed content (static), even though draft-1 came first expect(result[0].rule.actions).toEqual([]); expect(result[1].rule.actions).toHaveLength(1); expect(result[1].rule.actions[0].id).toBe("draft-2"); }); + + it("limits drafts when custom rule and resolved TO_REPLY both have DRAFT_EMAIL", () => { + const guestsRule = createRule("guests-rule", null, [ + getAction({ + id: "label-guest", + type: ActionType.LABEL, + label: "Guest Suggestion", + ruleId: "guests-rule", + }), + getAction({ + id: "draft-guest", + type: ActionType.DRAFT_EMAIL, + content: "Hi {{name}}, Thank you for reaching out.", + ruleId: "guests-rule", + }), + ]); + + const toReplyRuleResolved = createRule( + "to-reply-resolved", + SystemType.TO_REPLY, + [ + getAction({ + id: "label-to-reply", + type: ActionType.LABEL, + label: "To Reply", + ruleId: "to-reply-resolved", + }), + getAction({ + id: "draft-to-reply", + type: ActionType.DRAFT_EMAIL, + content: null, + ruleId: "to-reply-resolved", + }), + ], + ); + + const resolvedMatches = [ + { + rule: guestsRule, + matchReasons: undefined, + resolvedReason: undefined, + isConversationRule: false, + }, + { + rule: toReplyRuleResolved, + matchReasons: undefined, + resolvedReason: "Needs reply", + isConversationRule: true, + }, + ]; + + const result = limitDraftEmailActions(resolvedMatches, logger); + + expect(result[0].rule.actions).toHaveLength(2); + expect( + result[0].rule.actions.find((a) => a.type === ActionType.DRAFT_EMAIL)?.id, + ).toBe("draft-guest"); + expect(result[1].rule.actions).toHaveLength(1); + expect(result[1].rule.actions[0].type).toBe(ActionType.LABEL); + + const typedResult = result as typeof resolvedMatches; + expect(typedResult[0].isConversationRule).toBe(false); + expect(typedResult[1].isConversationRule).toBe(true); + expect(typedResult[1].resolvedReason).toBe("Needs reply"); + }); + + it("keeps first draft when both rules have AI-generated DRAFT_EMAIL", () => { + const guestsRule = createRule("guests-rule", null, [ + getAction({ + id: "draft-guest", + type: ActionType.DRAFT_EMAIL, + content: null, + ruleId: "guests-rule", + }), + ]); + + const toReplyRuleResolved = createRule( + "to-reply-resolved", + SystemType.TO_REPLY, + [ + getAction({ + id: "draft-to-reply", + type: ActionType.DRAFT_EMAIL, + content: null, + ruleId: "to-reply-resolved", + }), + ], + ); + + const result = limitDraftEmailActions( + [{ rule: guestsRule }, { rule: toReplyRuleResolved }], + logger, + ); + + expect(result[0].rule.actions).toHaveLength(1); + expect(result[0].rule.actions[0].id).toBe("draft-guest"); + expect(result[1].rule.actions).toEqual([]); + }); +}); + +describe("runRules - double draft prevention", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("executes only one DRAFT_EMAIL when custom rule and TO_REPLY both have drafts", async () => { + const { findMatchingRules } = await import( + "@/utils/ai/choose-rule/match-rules" + ); + const { determineConversationStatus } = await import( + "@/utils/reply-tracker/handle-conversation-status" + ); + const { getActionItemsWithAiArgs } = await import( + "@/utils/ai/choose-rule/choose-args" + ); + const { executeAct } = await import("@/utils/ai/choose-rule/execute"); + + const guestsRule = createRule("guests-rule", null, [ + getAction({ + id: "label-guest", + type: ActionType.LABEL, + label: "Guest Suggestion", + ruleId: "guests-rule", + }), + getAction({ + id: "draft-guest", + type: ActionType.DRAFT_EMAIL, + content: "Hi {{name}}, Please submit via our form.", + ruleId: "guests-rule", + }), + ]); + + const metaRule = createRule(CONVERSATION_TRACKING_META_RULE_ID, null, []); + + const toReplyWithDraft = createRule("to-reply-rule", SystemType.TO_REPLY, [ + getAction({ + id: "label-to-reply", + type: ActionType.LABEL, + label: "To Reply", + ruleId: "to-reply-rule", + }), + getAction({ + id: "draft-to-reply", + type: ActionType.DRAFT_EMAIL, + content: null, + ruleId: "to-reply-rule", + }), + ]); + + vi.mocked(findMatchingRules).mockResolvedValue({ + matches: [{ rule: guestsRule }, { rule: metaRule }], + reasoning: "Both rules matched", + }); + + vi.mocked(determineConversationStatus).mockResolvedValue({ + rule: toReplyWithDraft, + reason: "Email needs a reply", + }); + + vi.mocked(getActionItemsWithAiArgs).mockImplementation( + async ({ selectedRule }) => + selectedRule.actions.map((a) => ({ ...a, type: a.type as ActionType })), + ); + + const executedDraftContents: (string | null)[] = []; + vi.mocked(executeAct).mockImplementation(async ({ executedRule }) => { + for (const action of executedRule.actionItems) { + if (action.type === ActionType.DRAFT_EMAIL) { + executedDraftContents.push(action.content); + } + } + }); + + prisma.executedRule.findFirst.mockResolvedValue(null); + + let createCallCount = 0; + (prisma.executedRule.create as any).mockImplementation( + async (args: any) => { + const actionItems = args.data.actionItems?.createMany?.data || []; + createCallCount++; + return { + id: `exec-${createCallCount}`, + status: ExecutedRuleStatus.APPLYING, + ruleId: args.data.rule?.connect?.id ?? null, + threadId: args.data.threadId, + messageId: args.data.messageId, + actionItems: actionItems.map((a: any, idx: number) => ({ + ...a, + id: a.id || `action-${createCallCount}-${idx}`, + executedRuleId: `exec-${createCallCount}`, + })), + }; + }, + ); + + const message = { + ...getEmail(), + threadId, + snippet: "Test snippet", + historyId: "12345", + inline: [], + headers: { "message-id": "msg-1" }, + attachments: [], + } as any; + + await runRules({ + provider: {} as any, + message, + rules: [guestsRule, toReplyWithDraft], + emailAccount: getEmailAccount(), + isTest: false, + modelType: "actionable" as any, + logger, + }); + + expect(executedDraftContents).toHaveLength(1); + expect(executedDraftContents[0]).toBe( + "Hi {{name}}, Please submit via our form.", + ); + }); }); diff --git a/apps/web/utils/ai/choose-rule/run-rules.ts b/apps/web/utils/ai/choose-rule/run-rules.ts index dda1f797ab..f8df9ad9f8 100644 --- a/apps/web/utils/ai/choose-rule/run-rules.ts +++ b/apps/web/utils/ai/choose-rule/run-rules.ts @@ -105,7 +105,45 @@ export async function runRules({ logger, }); - const finalMatches = limitDraftEmailActions(conversationAwareMatches, logger); + // Separate regular matches from conversation meta-rule + const regularMatches = conversationAwareMatches.filter( + (m) => !isConversationRule(m.rule.id), + ); + const conversationMatch = conversationAwareMatches.find((m) => + isConversationRule(m.rule.id), + ); + + // Resolve conversation meta-rule to actual rule (e.g., TO_REPLY) + const matchesWithFlags: { + rule: RuleWithActions; + matchReasons?: MatchReason[]; + resolvedReason?: string; + isConversationRule: boolean; + }[] = regularMatches.map((m) => ({ + ...m, + isConversationRule: false, + })); + + if (conversationMatch) { + const { rule, reason } = await determineConversationStatus({ + conversationRules, + message, + emailAccount, + provider, + modelType, + isTest, + }); + if (rule) { + matchesWithFlags.push({ + rule, + matchReasons: conversationMatch.matchReasons, + resolvedReason: reason, + isConversationRule: true, + }); + } + } + + const finalMatches = limitDraftEmailActions(matchesWithFlags, logger); logger.trace("Matching rule", () => ({ module: MODULE, @@ -141,35 +179,10 @@ export async function runRules({ const executedRules: RunRulesResult[] = []; for (const result of finalMatches) { - let ruleToExecute = result.rule; - let reasonToUse = results.reasoning; - - if (result.rule && isConversationRule(result.rule.id)) { - const { rule: statusRule, reason: statusReason } = - await determineConversationStatus({ - conversationRules, - message, - emailAccount, - provider, - modelType, - isTest, - }); - - if (!statusRule) { - const executedRule: RunRulesResult = { - rule: null, - reason: statusReason || "No enabled conversation status rule found", - createdAt: batchTimestamp, - status: ExecutedRuleStatus.SKIPPED, - }; + const ruleToExecute = result.rule; + const reasonToUse = result.resolvedReason || results.reasoning; - executedRules.push(executedRule); - continue; - } - - ruleToExecute = statusRule; - reasonToUse = statusReason; - } else { + if (!result.isConversationRule) { analyzeSenderPatternIfAiMatch({ isTest, result, @@ -571,16 +584,9 @@ function isConversationRule(ruleId: string): boolean { * If there are no draft email actions, we return the matches as is. * If there is only one draft email action, we return the matches as is. */ -export function limitDraftEmailActions( - matches: { - rule: RuleWithActions; - matchReasons?: MatchReason[]; - }[], - logger: Logger, -): { - rule: RuleWithActions; - matchReasons?: MatchReason[]; -}[] { +export function limitDraftEmailActions< + T extends { rule: RuleWithActions; matchReasons?: MatchReason[] }, +>(matches: T[], logger: Logger): T[] { const draftCandidates = matches.flatMap((match) => match.rule.actions .filter((action) => action.type === ActionType.DRAFT_EMAIL) From e1c7c8884257800297548b26b9ca334ea0a171fb Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 1 Jan 2026 17:12:49 +0200 Subject: [PATCH 5/5] fix: preserve skip reason when conversation rule resolves to nothing --- apps/web/utils/ai/choose-rule/run-rules.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/utils/ai/choose-rule/run-rules.ts b/apps/web/utils/ai/choose-rule/run-rules.ts index f8df9ad9f8..e3dba35139 100644 --- a/apps/web/utils/ai/choose-rule/run-rules.ts +++ b/apps/web/utils/ai/choose-rule/run-rules.ts @@ -124,6 +124,8 @@ export async function runRules({ isConversationRule: false, })); + let skippedConversationReason: string | undefined; + if (conversationMatch) { const { rule, reason } = await determineConversationStatus({ conversationRules, @@ -140,6 +142,9 @@ export async function runRules({ resolvedReason: reason, isConversationRule: true, }); + } else { + // Track why conversation rule was skipped (e.g., determined FYI but rule disabled) + skippedConversationReason = reason; } } @@ -151,7 +156,8 @@ export async function runRules({ })); if (!finalMatches.length) { - const reason = results.reasoning || "No rules matched"; + const reason = + skippedConversationReason || results.reasoning || "No rules matched"; if (!isTest) { await prisma.executedRule.create({ data: {