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 { 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", }; } }