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
2 changes: 1 addition & 1 deletion apps/web/app/(app)/automation/TestRules.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,7 @@ export function TestResultDisplay({
)}
{!!result.reason && (
<div className="border-l-2 border-blue-200 pl-3 text-sm">
<span className="font-medium">AI Reasoning: </span>
<span className="font-medium">Reason: </span>
{result.reason}
</div>
)}
Expand Down
14 changes: 8 additions & 6 deletions apps/web/utils/ai/choose-rule/match-rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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"');
});
});

Expand Down
43 changes: 29 additions & 14 deletions apps/web/utils/ai/choose-rule/match-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
})[];
Expand Down Expand Up @@ -69,14 +70,16 @@ async function findPotentialMatchingRules({

const conditionTypes = getConditionTypes(rule);
const unmatchedConditions = new Set<string>(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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -125,13 +140,13 @@ export async function findMatchingRule(
user: Pick<User, "id" | "email" | "about"> & 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({
Expand Down Expand Up @@ -177,8 +192,7 @@ async function matchesGroupRule(
groups: Awaited<ReturnType<typeof getGroupsWithRules>>,
message: ParsedMessage,
) {
const match = findMatchingGroup(message, groups);
return !!match;
return findMatchingGroup(message, groups);
}

async function matchesCategoryRule(
Expand All @@ -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;
}
108 changes: 108 additions & 0 deletions apps/web/utils/group/find-matching-group.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
17 changes: 10 additions & 7 deletions apps/web/utils/group/find-matching-group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,21 @@ 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<
T extends Pick<GroupItem, "type" | "value">,
>(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);
}
Expand All @@ -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)
);
}
Expand All @@ -50,4 +51,6 @@ export function findMatchingGroupItem<

return false;
});

return matchingItem;
}
24 changes: 23 additions & 1 deletion apps/web/utils/string.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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",
);
});
});
});
19 changes: 10 additions & 9 deletions apps/web/utils/string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
);
}