diff --git a/apps/web/__tests__/eval/meeting-follow-up-draft.test.ts b/apps/web/__tests__/eval/meeting-follow-up-draft.test.ts index a870e35ef0..2be8522449 100644 --- a/apps/web/__tests__/eval/meeting-follow-up-draft.test.ts +++ b/apps/web/__tests__/eval/meeting-follow-up-draft.test.ts @@ -25,6 +25,7 @@ const ROLLOUT_SUMMARY: MeetingSummary = { { description: "Tell the customers who already have the fifteenth in writing", + owner: null, }, ], openQuestions: ["Who owns the migration webinar?"], @@ -45,6 +46,7 @@ const UNRESOLVED_PRICING_SUMMARY: MeetingSummary = { openQuestions: [ "Do they pay for all forty people or only the fifteen daily users?", ], + nextSteps: [], }; describe.runIf(shouldRunEval)("meeting-follow-up-draft eval", () => { @@ -146,7 +148,7 @@ describe.runIf(shouldRunEval)("meeting-follow-up-draft eval", () => { criterion: { name: "Ready to send", description: - "The email is addressed to the group and could be sent as written. It contains no unfilled placeholders for the sender to complete, no meta-commentary about being AI-generated, and no instructions to the reader about how to use the draft.", + "The email is addressed to the group and could be sent as written. It contains no unfilled placeholders for the sender to complete, no meta-commentary about being AI-generated, and no instructions to the reader about how to use the draft. Explicitly named action owners remain the owners; work that the source leaves unassigned may be omitted or described without an owner and must not be treated as a drafting placeholder.", }, input: JSON.stringify(ROLLOUT_SUMMARY, null, 2), output: `${draft.subject}\n\n${draft.body}`, diff --git a/apps/web/utils/ai/meeting-recorder/draft-meeting-follow-up.ts b/apps/web/utils/ai/meeting-recorder/draft-meeting-follow-up.ts index f0b4501625..2d16d5e3a4 100644 --- a/apps/web/utils/ai/meeting-recorder/draft-meeting-follow-up.ts +++ b/apps/web/utils/ai/meeting-recorder/draft-meeting-follow-up.ts @@ -16,6 +16,9 @@ The user reviews and sends this themselves, so write it as them, ready to send. Rules: - Only reference what the meeting actually covered. Never invent a commitment, a deadline, a price or a next step that was not agreed. - Do not promise anything on the user's behalf that they did not say they would do. +- Preserve the owner of every named commitment. Do not rewrite another person's action as "I will"; use first person only when the summary establishes that the sender owns the action. +- When an action has no owner, keep it unassigned and describe the agreed work without inventing an owner or turning the email into a request to fill in missing information. +- The sender is identified by email. Do not assume the sender is any differently named owner in the summary. - Recap only what is useful to the recipients. This is an email, not a transcript: a few short paragraphs or a small list. - Address the recipients as a group. Do not open with a placeholder like "[Name]". - If a writing style is provided, match the user's tone and formality. @@ -132,5 +135,7 @@ ${JSON.stringify(summary, null, 2)} ${getTodayForLLM()} -Write the follow-up email as ${emailAccount.email}.`; +Sender: ${emailAccount.email} + +Write the follow-up email as the sender.`; } diff --git a/apps/web/utils/ai/meeting-recorder/summarize-meeting.ts b/apps/web/utils/ai/meeting-recorder/summarize-meeting.ts index eb82670e67..3436d2b310 100644 --- a/apps/web/utils/ai/meeting-recorder/summarize-meeting.ts +++ b/apps/web/utils/ai/meeting-recorder/summarize-meeting.ts @@ -19,6 +19,10 @@ Rules: - When the meeting reverses an earlier decision, report the final position, not the one that was superseded. - If a speaker label looks wrong or two speakers have similar names, prefer describing what was decided over who said it. - Leave a section empty when the meeting genuinely had nothing for it. An empty list is better than a filler entry. +- Put each fact in the single section that best describes it. A decision is a settled choice, not merely a commitment or deadline. Do not repeat an action item as a next step or restate a decision in another section. +- Combine closely related work into one action item when the same person took responsibility for the same outcome. +- Keep an action's deadline or timing in that action item's description. Do not move task timing into next steps. +- Keep the length proportionate to the meeting. For a short, single-topic meeting, use a brief overview and only the distinct details someone needs to remember. - Write in the language the meeting was held in. - Write for someone who attended and wants a reminder, not for someone who needs the meeting re-narrated. @@ -27,29 +31,41 @@ Return your response in JSON format.`; const summarySchema = z.object({ overview: z .string() - .describe("A short paragraph covering what the meeting was about"), + .describe( + "One or two sentences covering what the meeting was about, without repeating the detailed lists", + ), keyDecisions: z .array(z.string()) - .describe("Decisions the group actually settled on"), + .describe( + "Choices the group actually settled on; exclude action items, deadlines and status updates", + ), actionItems: z .array( z.object({ - description: z.string(), + description: z + .string() + .describe( + "The agreed work and any deadline or timing attached to that work", + ), owner: z .string() - .optional() - .describe("Only set when the transcript shows who took this on"), + .nullable() + .describe( + "The person who took this on, or null when the transcript does not establish an owner", + ), }), ) - .describe("Concrete follow-up work agreed in the meeting"), + .describe( + "Distinct concrete follow-up work agreed in the meeting, combining closely related work with the same owner", + ), openQuestions: z .array(z.string()) - .optional() .describe("Questions raised but left unresolved"), nextSteps: z .array(z.string()) - .optional() - .describe("What happens next, including any agreed timing"), + .describe( + "Separate scheduled events such as another meeting; never include action deadlines or restate action items, and use an empty list when there is no separate event", + ), }); export type MeetingSummary = z.infer; diff --git a/apps/web/utils/meeting-recorder/send-recap.test.ts b/apps/web/utils/meeting-recorder/send-recap.test.ts index 3292b77735..3ff783ea3f 100644 --- a/apps/web/utils/meeting-recorder/send-recap.test.ts +++ b/apps/web/utils/meeting-recorder/send-recap.test.ts @@ -52,6 +52,8 @@ describe("sendMeetingRecapEmail", () => { overview: "Discussed the plan.", keyDecisions: [], actionItems: [], + openQuestions: [], + nextSteps: [], }, followUpDraftCreated: false, logger: { diff --git a/packages/resend/emails/meeting-recap.tsx b/packages/resend/emails/meeting-recap.tsx index bd44dfdd13..c49444bfd2 100644 --- a/packages/resend/emails/meeting-recap.tsx +++ b/packages/resend/emails/meeting-recap.tsx @@ -12,7 +12,7 @@ import { export type MeetingRecapActionItem = { description: string; - owner?: string; + owner?: string | null; }; export type MeetingRecapContent = {