120 lines
3.7 KiB
TypeScript
120 lines
3.7 KiB
TypeScript
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",
|
|
};
|
|
}
|
|
}
|