diff --git a/apps/web/app/(app)/automation/TestRules.tsx b/apps/web/app/(app)/automation/TestRules.tsx index 31a75f4ee0..2a1635fd87 100644 --- a/apps/web/app/(app)/automation/TestRules.tsx +++ b/apps/web/app/(app)/automation/TestRules.tsx @@ -396,7 +396,7 @@ export function TestResultDisplay({ )} {!!result.reason && (
- AI Reasoning: + Reason: {result.reason}
)} diff --git a/apps/web/utils/ai/choose-rule/match-rules.test.ts b/apps/web/utils/ai/choose-rule/match-rules.test.ts index 7aa619432b..2d2804746b 100644 --- a/apps/web/utils/ai/choose-rule/match-rules.test.ts +++ b/apps/web/utils/ai/choose-rule/match-rules.test.ts @@ -42,7 +42,7 @@ describe("findMatchingRule", () => { const result = await findMatchingRule(rules, message, user); expect(result.rule?.id).toBe(rule.id); - expect(result.reason).toBeUndefined(); + expect(result.reason).toBe("Matched static conditions"); }); it("matches a static domain", async () => { @@ -56,7 +56,7 @@ describe("findMatchingRule", () => { const result = await findMatchingRule(rules, message, user); expect(result.rule?.id).toBe(rule.id); - expect(result.reason).toBeUndefined(); + expect(result.reason).toBe("Matched static conditions"); }); it("doens't match wrong static domain", async () => { @@ -93,7 +93,7 @@ describe("findMatchingRule", () => { const result = await findMatchingRule(rules, message, user); expect(result.rule?.id).toBe(rule.id); - expect(result.reason).toBeUndefined(); + expect(result.reason).toBe(`Matched group item: "FROM: test@example.com"`); }); it("matches a smart category rule", async () => { @@ -112,7 +112,7 @@ describe("findMatchingRule", () => { const result = await findMatchingRule(rules, message, user); expect(result.rule?.id).toBe(rule.id); - expect(result.reason).toBeUndefined(); + expect(result.reason).toBe('Matched category: "category"'); }); it("matches a smart category rule with exclude", async () => { @@ -161,7 +161,9 @@ describe("findMatchingRule", () => { const result = await findMatchingRule(rules, message, user); expect(result.rule?.id).toBe(rule.id); - expect(result.reason).toBeUndefined(); + expect(result.reason).toBe( + `Matched group item: "FROM: test@example.com", Matched category: "category"`, + ); }); it("matches a rule with multiple conditions AND (category and AI)", async () => { @@ -265,7 +267,7 @@ describe("findMatchingRule", () => { const result = await findMatchingRule(rules, message, user); expect(result.rule?.id).toBe(rule.id); - expect(result.reason).toBeUndefined(); + expect(result.reason).toBe('Matched category: "category"'); }); }); diff --git a/apps/web/utils/ai/choose-rule/match-rules.ts b/apps/web/utils/ai/choose-rule/match-rules.ts index 13ef314b38..9648d19350 100644 --- a/apps/web/utils/ai/choose-rule/match-rules.ts +++ b/apps/web/utils/ai/choose-rule/match-rules.ts @@ -20,6 +20,7 @@ import type { UserAIFields } from "@/utils/llms/types"; // ai rules need further processing to determine if they match type MatchingRuleResult = { match?: RuleWithActionsAndCategories; + reason?: string; potentialMatches?: (RuleWithActionsAndCategories & { instructions: string; })[]; @@ -69,14 +70,16 @@ async function findPotentialMatchingRules({ const conditionTypes = getConditionTypes(rule); const unmatchedConditions = new Set(Object.keys(conditionTypes)); + const matchReasons: string[] = []; // static if (conditionTypes.STATIC) { const match = matchesStaticRule(rule, message); if (match) { unmatchedConditions.delete("STATIC"); + matchReasons.push("Matched static conditions"); if (operator === LogicalOperator.OR || !unmatchedConditions.size) - return { match: rule }; + return { match: rule, reason: matchReasons.join(", ") }; } else { // no match, so can't be a match with AND if (operator === LogicalOperator.AND) continue; @@ -85,11 +88,21 @@ async function findPotentialMatchingRules({ // group if (conditionTypes.GROUP) { - const match = await matchesGroupRule(await getGroups(rule), message); - if (match) { + const { matchingItem } = await matchesGroupRule( + await getGroups(rule), + message, + ); + if (matchingItem) { unmatchedConditions.delete("GROUP"); - if (operator === LogicalOperator.OR || !unmatchedConditions.size) - return { match: rule }; + matchReasons.push( + `Matched group item: "${matchingItem.type}: ${matchingItem.value}"`, + ); + if (operator === LogicalOperator.OR || !unmatchedConditions.size) { + return { + match: rule, + reason: matchReasons.join(", "), + }; + } } else { // no match, so can't be a match with AND if (operator === LogicalOperator.AND) continue; @@ -101,8 +114,10 @@ async function findPotentialMatchingRules({ const match = await matchesCategoryRule(rule, await getSender(rule)); if (match) { unmatchedConditions.delete("CATEGORY"); + if (typeof match !== "boolean") + matchReasons.push(`Matched category: "${match.name}"`); if (operator === LogicalOperator.OR || !unmatchedConditions.size) - return { match: rule }; + return { match: rule, reason: matchReasons.join(", ") }; } else { // no match, so can't be a match with AND if (operator === LogicalOperator.AND) continue; @@ -125,13 +140,13 @@ export async function findMatchingRule( user: Pick & UserAIFields, ): Promise<{ rule?: RuleWithActionsAndCategories; reason?: string }> { const isThread = isReplyInThread(message.id, message.threadId); - const { match, potentialMatches } = await findPotentialMatchingRules({ + const { match, reason, potentialMatches } = await findPotentialMatchingRules({ rules, message, isThread, }); - if (match) return { rule: match, reason: undefined }; + if (match) return { rule: match, reason }; if (potentialMatches?.length) { const result = await aiChooseRule({ @@ -177,8 +192,7 @@ async function matchesGroupRule( groups: Awaited>, message: ParsedMessage, ) { - const match = findMatchingGroup(message, groups); - return !!match; + return findMatchingGroup(message, groups); } async function matchesCategoryRule( @@ -190,16 +204,17 @@ async function matchesCategoryRule( if (!sender) return false; - const isIncluded = rule.categoryFilters.some( + const matchedFilter = rule.categoryFilters.find( (c) => c.id === sender.categoryId, ); if ( - (rule.categoryFilterType === CategoryFilterType.INCLUDE && !isIncluded) || - (rule.categoryFilterType === CategoryFilterType.EXCLUDE && isIncluded) + (rule.categoryFilterType === CategoryFilterType.INCLUDE && + !matchedFilter) || + (rule.categoryFilterType === CategoryFilterType.EXCLUDE && matchedFilter) ) { return false; } - return true; + return matchedFilter; } diff --git a/apps/web/utils/group/find-matching-group.test.ts b/apps/web/utils/group/find-matching-group.test.ts new file mode 100644 index 0000000000..4f3a8d2847 --- /dev/null +++ b/apps/web/utils/group/find-matching-group.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { findMatchingGroupItem } from "./find-matching-group"; +import { GroupItemType } from "@prisma/client"; + +// Run with: +// pnpm test utils/group/find-matching-group.test.ts + +describe("findMatchingGroupItem", () => { + it("should match FROM rules", () => { + const groupItems = [ + { type: GroupItemType.FROM, value: "newsletter@company.com" }, + { type: GroupItemType.FROM, value: "@company.com" }, + ]; + + // Full email match + expect( + findMatchingGroupItem( + { from: "newsletter@company.com", subject: "" }, + groupItems, + ), + ).toBe(groupItems[0]); + + // Partial domain match + expect( + findMatchingGroupItem( + { from: "support@company.com", subject: "" }, + groupItems, + ), + ).toBe(groupItems[1]); + + // No match + expect( + findMatchingGroupItem( + { from: "someone@other.com", subject: "" }, + groupItems, + ), + ).toBeUndefined(); + }); + + it("should match SUBJECT rules", () => { + const groupItems = [ + { type: GroupItemType.SUBJECT, value: "Invoice" }, + { type: GroupItemType.SUBJECT, value: "[GitHub]" }, + ]; + + // Exact subject match + expect( + findMatchingGroupItem({ from: "", subject: "Invoice #123" }, groupItems), + ).toBe(groupItems[0]); + + // Match after number removal + expect( + findMatchingGroupItem( + { from: "", subject: "Invoice INV-2023-001 from Company" }, + groupItems, + ), + ).toBe(groupItems[0]); + + // GitHub notification match + expect( + findMatchingGroupItem( + { from: "", subject: "[GitHub] PR #456: Fix bug" }, + groupItems, + ), + ).toBe(groupItems[1]); + + // No match + expect( + findMatchingGroupItem( + { from: "", subject: "Welcome to our service" }, + groupItems, + ), + ).toBeUndefined(); + }); + + it("should handle empty inputs", () => { + const groupItems = [ + { type: GroupItemType.FROM, value: "test@example.com" }, + { type: GroupItemType.SUBJECT, value: "Test" }, + ]; + + expect( + findMatchingGroupItem({ from: "", subject: "" }, groupItems), + ).toBeUndefined(); + + expect( + findMatchingGroupItem( + { from: "test@example.com", subject: "" }, + groupItems, + ), + ).toBe(groupItems[0]); + }); + + it("should prioritize first matching rule", () => { + const groupItems = [ + { type: GroupItemType.SUBJECT, value: "Invoice" }, + { type: GroupItemType.SUBJECT, value: "Company" }, + ]; + + // Should return first matching rule even though both would match + expect( + findMatchingGroupItem( + { from: "", subject: "Invoice from Company" }, + groupItems, + ), + ).toBe(groupItems[0]); + }); +}); diff --git a/apps/web/utils/group/find-matching-group.ts b/apps/web/utils/group/find-matching-group.ts index fe91df4d3e..4b7fc87003 100644 --- a/apps/web/utils/group/find-matching-group.ts +++ b/apps/web/utils/group/find-matching-group.ts @@ -15,10 +15,13 @@ export function findMatchingGroup( message: ParsedMessage, groups: GroupsWithRules, ) { - const group = groups.find((group) => - findMatchingGroupItem(message.headers, group.items), - ); - return group; + for (const group of groups) { + const matchingItem = findMatchingGroupItem(message.headers, group.items); + if (matchingItem) { + return { group, matchingItem }; + } + } + return { group: null, matchingItem: null }; } export function findMatchingGroupItem< @@ -26,7 +29,7 @@ export function findMatchingGroupItem< >(headers: { from: string; subject: string }, groupItems: T[]) { const { from, subject } = headers; - return groupItems.find((item) => { + const matchingItem = groupItems.find((item) => { if (item.type === GroupItemType.FROM && from) { return item.value.includes(from) || from.includes(item.value); } @@ -36,9 +39,7 @@ export function findMatchingGroupItem< const valueWithoutNumbers = generalizeSubject(item.value); return ( - item.value.includes(subject) || subject.includes(item.value) || - valueWithoutNumbers.includes(subjectWithoutNumbers) || subjectWithoutNumbers.includes(valueWithoutNumbers) ); } @@ -50,4 +51,6 @@ export function findMatchingGroupItem< return false; }); + + return matchingItem; } diff --git a/apps/web/utils/string.test.ts b/apps/web/utils/string.test.ts index ff2e9e8c60..0218ba502f 100644 --- a/apps/web/utils/string.test.ts +++ b/apps/web/utils/string.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from "vitest"; -import { removeExcessiveWhitespace, truncate } from "./string"; +import { + removeExcessiveWhitespace, + truncate, + generalizeSubject, +} from "./string"; // Run with: // pnpm test utils/string.test.ts @@ -63,4 +67,22 @@ describe("string utils", () => { expect(removeExcessiveWhitespace(input)).toBe("hello world\n\ntest"); }); }); + describe("generalizeSubject", () => { + it("should remove numbers and IDs", () => { + expect(generalizeSubject("Order #123")).toBe("Order"); + expect(generalizeSubject("Invoice 456")).toBe("Invoice"); + expect(generalizeSubject("[org/repo] PR #789: Fix bug (abc123)")).toBe( + "[org/repo] PR : Fix bug", + ); + }); + + it("should preserve normal text", () => { + expect(generalizeSubject("Welcome to our service")).toBe( + "Welcome to our service", + ); + expect(generalizeSubject("Your account has been created")).toBe( + "Your account has been created", + ); + }); + }); }); diff --git a/apps/web/utils/string.ts b/apps/web/utils/string.ts index fcb7054c2a..8e617f765c 100644 --- a/apps/web/utils/string.ts +++ b/apps/web/utils/string.ts @@ -25,13 +25,14 @@ export function removeExcessiveWhitespace(str: string) { } export function generalizeSubject(subject = "") { - // replace numbers to make subject more generic - // also removes [], () ,and words that start with # - const regex = - /(\b\d+(\.\d+)?(-\d+(\.\d+)?)?(\b|[A-Za-z])|\[.*?\]|\(.*?\)|\b#\w+)/g; - - // remove any words that contain numbers - const regexRemoveNumberWords = /\b\w*\d\w*\b/g; - - return subject.replaceAll(regexRemoveNumberWords, "").replaceAll(regex, ""); + return ( + subject + // Remove content in parentheses + .replace(/\([^)]*\)/g, "") + // Remove numbers and IDs + .replace(/(?:#\d+|\b\d+\b)/g, "") + // Clean up extra whitespace + .replace(/\s+/g, " ") + .trim() + ); }