Add offline merchant category suggestions
This commit is contained in:
119
src/lib/category-suggestion.ts
Normal file
119
src/lib/category-suggestion.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user