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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apps/web/__tests__/ai-prompt-to-rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ describe.skipIf(!isAiTest)("aiPromptToRules", () => {
user,
promptFile,
isEditing: false,
hasSmartCategories: false,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add test coverage for smart categories.

While the hasSmartCategories parameter is properly added to existing tests, we should add test cases with hasSmartCategories=true to ensure the category functionality works correctly.

Consider adding these test cases:

  1. Converting prompts with category-specific rules
  2. Error handling with invalid category formats
  3. Mixed rules with and without categories

Example test structure:

it("should handle smart categories in rules", async () => {
  const user = {
    email: "user@test.com",
    aiModel: null,
    aiProvider: null,
    aiApiKey: null,
  };

  const prompts = [
    `* Add to category "Important" when from boss@company.com`,
    `* Move to category "Archive" all newsletters`,
  ];

  const result = await aiPromptToRules({
    user,
    promptFile: prompts.join("\n"),
    isEditing: false,
    hasSmartCategories: true,
  });

  // Add appropriate assertions
});

Also applies to: 126-131

});

console.log(JSON.stringify(result, null, 2));
Expand Down Expand Up @@ -122,7 +123,12 @@ describe.skipIf(!isAiTest)("aiPromptToRules", () => {
const promptFile = "Some prompt";

await expect(
aiPromptToRules({ user, promptFile, isEditing: false }),
aiPromptToRules({
user,
promptFile,
isEditing: false,
hasSmartCategories: false,
}),
).rejects.toThrow();
});
});
10 changes: 7 additions & 3 deletions apps/web/app/(app)/automation/RulesPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,12 @@ export function RulesPrompt() {
mutate={mutate}
onOpenPersonaDialog={onOpenPersonaDialog}
/>
<AutomationOnboarding
onComplete={() => {
if (!data?.rulesPrompt) onOpenPersonaDialog();
}}
/>
</LoadingContent>
<AutomationOnboarding onComplete={onOpenPersonaDialog} />
<PersonaDialog
isOpen={isModalOpen}
setIsOpen={setIsModalOpen}
Expand Down Expand Up @@ -220,7 +224,7 @@ Let me know if you're interested!
</email>`}
/>

<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<Button
type="submit"
disabled={isSubmitting || isGenerating}
Expand Down Expand Up @@ -286,7 +290,7 @@ Let me know if you're interested!
</form>
</CardContent>
</div>
<div className="px-6 pb-4 sm:mt-8 sm:p-0">
<div className="px-4 pb-4 sm:mt-8 sm:p-0 sm:px-6">
<SectionHeader>Examples</SectionHeader>

<ScrollArea className="mt-2 sm:h-[600px] sm:max-h-[600px]">
Expand Down
4 changes: 2 additions & 2 deletions apps/web/components/NavBottom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ function NavBarBottom({
// safe area for iOS PWA
style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
>
<nav className="flex h-14 items-center justify-around">
<nav className="grid h-14 grid-cols-4">
{links.map((link) => {
return (
<Link
Expand All @@ -50,7 +50,7 @@ function NavBarBottom({
const links = [
{
path: "/automation",
label: "AI Personal Assistant",
label: "Assistant",
icon: SparklesIcon,
},
{
Expand Down
6 changes: 6 additions & 0 deletions apps/web/utils/actions/ai-rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,7 @@ export const saveRulesPromptAction = withActionInstrumentation(
aiModel: true,
aiApiKey: true,
email: true,
categories: { select: { id: true } },
},
});

Expand Down Expand Up @@ -572,6 +573,8 @@ export const saveRulesPromptAction = withActionInstrumentation(
let editRulesCount = 0;
let removeRulesCount = 0;

const hasSmartCategories = user.categories.length > 0;

// check how the prompts have changed, and make changes to the rules accordingly
if (oldPromptFile) {
logger.info("Comparing old and new prompts", {
Expand Down Expand Up @@ -609,6 +612,7 @@ export const saveRulesPromptAction = withActionInstrumentation(
user: { ...user, email: user.email },
promptFile: diff.addedRules.join("\n\n"),
isEditing: false,
hasSmartCategories,
});
logger.info("Added rules", {
email: user.email,
Expand Down Expand Up @@ -691,6 +695,7 @@ export const saveRulesPromptAction = withActionInstrumentation(
)
.join("\n\n"),
isEditing: true,
hasSmartCategories,
});

for (const rule of editedRules) {
Expand Down Expand Up @@ -736,6 +741,7 @@ export const saveRulesPromptAction = withActionInstrumentation(
user: { ...user, email: user.email },
promptFile: data.rulesPrompt,
isEditing: false,
hasSmartCategories,
});
logger.info("Rules to be added", {
email: user.email,
Expand Down
100 changes: 63 additions & 37 deletions apps/web/utils/ai/rule/create-rule-schema.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,52 @@
import { z } from "zod";
import { GroupName } from "@/utils/config";
import { ActionType, RuleType } from "@prisma/client";
import { ActionType, CategoryFilterType, RuleType } from "@prisma/client";

const typeSchema = z.enum([RuleType.AI, RuleType.STATIC, RuleType.GROUP]);
const allTypesSchema = z.enum([
RuleType.AI,
RuleType.STATIC,
RuleType.GROUP,
RuleType.CATEGORY,
]);

const conditionSchema = z
.object({
aiInstructions: z
.string()
.optional()
.describe(
"Instructions for the AI to determine when to apply this rule. For example: 'Apply this rule to emails about product updates' or 'Use this rule for messages discussing project deadlines'. Be specific about the email content or characteristics that should trigger this rule. Leave blank if using static conditions or groups.",
),
static: z
.object({
from: z.string().optional().describe("The from email address to match"),
to: z.string().optional().describe("The to email address to match"),
subject: z
.string()
.optional()
.describe(
"The subject to match. Leave blank if AI is required to process the subject line.",
),
})
.optional()
.describe("The static conditions to match"),
group: z
.enum([GroupName.RECEIPT, GroupName.NEWSLETTER])
.optional()
.describe(
"The group to match. Only 'Receipt' and 'Newsletter' are supported.",
),
})
.describe("The conditions to match");

export const createRuleSchema = z.object({
name: z
.string()
.describe("The name of the rule. No need to include 'Rule' in the name."),
condition: z
.object({
type: z
.enum([RuleType.AI, RuleType.STATIC, RuleType.GROUP])
.describe("The type of the condition"),

aiInstructions: z
.string()
.optional()
.describe(
"Instructions for the AI to determine when to apply this rule. For example: 'Apply this rule to emails about product updates' or 'Use this rule for messages discussing project deadlines'. Be specific about the email content or characteristics that should trigger this rule. Leave blank if using static conditions or groups.",
),
static: z
.object({
from: z
.string()
.optional()
.describe("The from email address to match"),
to: z.string().optional().describe("The to email address to match"),
subject: z
.string()
.optional()
.describe(
"The subject to match. Leave blank if AI is required to process the subject line.",
),
})
.optional()
.describe("The static conditions to match"),
group: z
.enum([GroupName.RECEIPT, GroupName.NEWSLETTER])
.optional()
.describe(
"The group to match. Only 'Receipt' and 'Newsletter' are supported.",
),
})
.describe("The conditions to match"),
condition: conditionSchema.extend({
type: typeSchema.describe("The type of the condition"),
}),
actions: z
.array(
z.object({
Expand Down Expand Up @@ -78,6 +83,11 @@ export const createRuleSchema = z.object({
.nullish()
.transform((v) => v ?? null)
.describe("The content of the email"),
webhookUrl: z
.string()
.nullish()
.transform((v) => v ?? null)
.describe("The webhook URL to call"),
})
.optional()
.describe(
Expand All @@ -87,3 +97,19 @@ export const createRuleSchema = z.object({
)
.describe("The actions to take"),
});

export const createRuleSchemaWithCategories = createRuleSchema.extend({
condition: conditionSchema.extend({
type: allTypesSchema.describe("The type of the condition"),
categoryFilterType: z
.enum([CategoryFilterType.INCLUDE, CategoryFilterType.EXCLUDE])
.optional()
.describe(
"Whether senders in this categoryFilters should be included or excluded",
),
categoryFilters: z
.array(z.string())
.optional()
.describe("The categories to match"),
}),
});
1 change: 1 addition & 0 deletions apps/web/utils/ai/rule/create-rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export async function aiCreateRule(
bcc: action.fields?.bcc ?? undefined,
subject: action.fields?.subject ?? undefined,
content: action.fields?.content ?? undefined,
webhookUrl: action.fields?.webhookUrl ?? undefined,
})),
};
}
21 changes: 19 additions & 2 deletions apps/web/utils/ai/rule/prompt-to-rules.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,41 @@
import { z } from "zod";
import { chatCompletionTools } from "@/utils/llms";
import type { UserAIFields } from "@/utils/llms/types";
import { createRuleSchema } from "@/utils/ai/rule/create-rule-schema";
import {
createRuleSchema,
createRuleSchemaWithCategories,
} from "@/utils/ai/rule/create-rule-schema";
import { createScopedLogger } from "@/utils/logger";

const logger = createScopedLogger("ai-prompt-to-rules");

const updateRuleSchema = createRuleSchema.extend({
ruleId: z.string().optional(),
});
const updateRuleSchemaWithCategories = createRuleSchemaWithCategories.extend({
ruleId: z.string().optional(),
});

export async function aiPromptToRules({
user,
promptFile,
isEditing,
hasSmartCategories,
}: {
user: UserAIFields & { email: string };
promptFile: string;
isEditing: boolean;
hasSmartCategories: boolean;
}) {
const schema = isEditing ? updateRuleSchema : createRuleSchema;
function getSchema() {
if (hasSmartCategories)
return isEditing
? updateRuleSchemaWithCategories
: createRuleSchemaWithCategories;
return isEditing ? updateRuleSchema : createRuleSchema;
}

const schema = getSchema();

const parameters = z.object({
rules: z
Expand Down Expand Up @@ -69,6 +85,7 @@ IMPORTANT: If a user provides a snippet, use that full snippet in the rule. Don'
bcc: action.fields?.bcc ?? undefined,
subject: action.fields?.subject ?? undefined,
content: action.fields?.content ?? undefined,
webhookUrl: action.fields?.webhookUrl ?? undefined,
})),
}));
}