Add offline merchant category suggestions

This commit is contained in:
2026-03-23 13:28:00 -04:00
parent 12c72ddcad
commit 696d393fca
11 changed files with 352 additions and 31 deletions

View File

@@ -0,0 +1,119 @@
import { CATEGORY_VALUES, type CategoryValue } from "@/lib/categories";
type SuggestionSource = "rule" | "model" | "unavailable";
export type CategorySuggestion = {
category: CategoryValue | null;
message: string;
merchantName: string;
requiresConfirmation: boolean;
source: SuggestionSource;
};
type MerchantRule = {
category: CategoryValue;
patterns: RegExp[];
};
const MERCHANT_RULES: MerchantRule[] = [
{ category: "SHOPPING", patterns: [/amazon/i, /flipkart/i, /myntra/i] },
{ category: "FOOD", patterns: [/swiggy/i, /zomato/i, /starbucks/i, /instamart/i, /blinkit/i] },
{ category: "TRANSPORT", patterns: [/uber/i, /ola/i, /rapido/i, /metro/i] },
{ category: "BILLS", patterns: [/airtel/i, /jio/i, /electricity/i, /water bill/i] },
{ category: "HEALTH", patterns: [/apollo/i, /pharmacy/i, /clinic/i, /hospital/i] },
{ category: "ENTERTAINMENT", patterns: [/netflix/i, /spotify/i, /bookmyshow/i] },
{ category: "RENT", patterns: [/rent/i, /landlord/i, /lease/i] },
];
export function normalizeMerchantName(value: string) {
return value.trim().replace(/\s+/g, " ");
}
export function getMerchantRuleCategory(merchantName: string): CategoryValue | null {
const normalized = normalizeMerchantName(merchantName);
for (const rule of MERCHANT_RULES) {
if (rule.patterns.some((pattern) => pattern.test(normalized))) {
return rule.category;
}
}
return null;
}
function parseSuggestedCategory(raw: unknown): CategoryValue | null {
if (typeof raw !== "string") {
return null;
}
const normalized = raw.trim().toUpperCase();
return CATEGORY_VALUES.includes(normalized as CategoryValue) ? (normalized as CategoryValue) : null;
}
export async function suggestCategoryForMerchant(merchantName: string): Promise<CategorySuggestion> {
const normalized = normalizeMerchantName(merchantName);
if (!normalized) {
return {
category: null,
message: "Enter a merchant name to get a suggestion.",
merchantName: normalized,
requiresConfirmation: false,
source: "unavailable",
};
}
const matchedCategory = getMerchantRuleCategory(normalized);
if (matchedCategory) {
return {
category: matchedCategory,
message: "Known merchant matched locally. Category auto-filled.",
merchantName: normalized,
requiresConfirmation: false,
source: "rule",
};
}
try {
const response = await fetch(`${process.env.OLLAMA_URL ?? "http://127.0.0.1:11434"}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: process.env.OLLAMA_MODEL ?? "qwen2.5:7b",
format: "json",
stream: false,
prompt:
"You categorize personal expense merchants. Return JSON with one key named category. Allowed values only: RENT, FOOD, TRANSPORT, BILLS, SHOPPING, HEALTH, ENTERTAINMENT, MISC. Merchant: " +
normalized,
}),
});
if (!response.ok) {
throw new Error(`Ollama request failed with ${response.status}`);
}
const payload = (await response.json()) as { response?: string };
const parsed = payload.response ? JSON.parse(payload.response) : null;
const category = parseSuggestedCategory(parsed?.category);
if (!category) {
throw new Error("Model did not return a valid category.");
}
return {
category,
message: "Local AI suggested a category. Review before saving.",
merchantName: normalized,
requiresConfirmation: true,
source: "model",
};
} catch {
return {
category: null,
message: "Local AI suggestion is unavailable right now. Choose a category manually.",
merchantName: normalized,
requiresConfirmation: false,
source: "unavailable",
};
}
}