Add offline merchant category suggestions
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
## Context
|
||||
|
||||
The repository starts with a product plan and OpenSpec configuration but no application code. The first version needs a complete local-first implementation using `Next.js`, `Prisma`, `SQLite`, and `OpenAI`, while keeping scope intentionally narrow: one user, manual data entry, fixed categories, and dashboard-only insights. Month boundaries are based on the local machine timezone, which affects date parsing, monthly aggregation, and paycheck coverage calculations.
|
||||
The repository starts with a product plan and OpenSpec configuration but no application code. The first version needs a complete local-first implementation using `Next.js`, `Prisma`, `SQLite`, and a fully offline local LLM runtime, while keeping scope intentionally narrow: one user, manual data entry, fixed categories, merchant-assisted categorization, and dashboard-only insights. Month boundaries are based on the local machine timezone, which affects date parsing, monthly aggregation, and paycheck coverage calculations.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
@@ -8,12 +8,13 @@ The repository starts with a product plan and OpenSpec configuration but no appl
|
||||
- Build a single deployable `Next.js` app with UI views and server routes in one codebase.
|
||||
- Persist expenses, paychecks, and generated monthly insights in a local SQLite database managed by Prisma.
|
||||
- Centralize monthly aggregation logic so dashboard reads and AI generation use the same numbers.
|
||||
- Keep AI integration isolated behind a small service layer that prepares structured monthly context and calls `OpenAI`.
|
||||
- Keep AI integration isolated behind a small service layer that prepares structured monthly context and calls a fully offline local inference runtime.
|
||||
- Make v1 testable with deterministic validation, aggregation, and safe fallback behavior for sparse data.
|
||||
- Add privacy-preserving merchant category suggestion with deterministic merchant mappings before model inference.
|
||||
|
||||
**Non-Goals:**
|
||||
- Authentication, multi-user support, bank sync, receipt scanning, background jobs, or email delivery.
|
||||
- Automatic categorization, editing data through AI, or free-form custom categories in v1.
|
||||
- Fully automatic uncapped categorization without user review for ambiguous merchants, editing data through AI, or free-form custom categories in v1.
|
||||
- Complex financial forecasting beyond simple next-month guidance derived from recent activity.
|
||||
|
||||
## Decisions
|
||||
@@ -34,16 +35,25 @@ The repository starts with a product plan and OpenSpec configuration but no appl
|
||||
- Rationale: dashboard totals, paycheck coverage, category breakdowns, and AI snapshots must stay consistent across endpoints.
|
||||
- Alternative considered: separate logic per route. Rejected because it risks drift between dashboard and insight generation.
|
||||
|
||||
### Use `Ollama` with a local Qwen-class instruct model
|
||||
- Rationale: privacy is a primary product requirement, and the target machine can comfortably run a recent local model for lightweight categorization and summary generation.
|
||||
- Alternative considered: hosted `OpenAI`. Rejected because it violates the privacy-first goal for personal financial data.
|
||||
|
||||
### Add an AI service boundary with structured prompt input and fallback responses
|
||||
- Rationale: the app needs provider isolation, predictable prompt shape, and safe messaging when data is too sparse for useful advice.
|
||||
- Alternative considered: calling `OpenAI` directly from a route handler with raw records. Rejected because it couples prompting, aggregation, and transport too tightly.
|
||||
- Rationale: the app needs runtime isolation, predictable prompt shape, and safe messaging when local inference is unavailable or data is too sparse for useful advice.
|
||||
- Alternative considered: calling the local model directly from a route handler with raw records. Rejected because it couples prompting, aggregation, and transport too tightly.
|
||||
|
||||
### Use merchant rules first and local-model fallback second for category suggestion
|
||||
- Rationale: most repeated merchants can be categorized deterministically and faster than model inference, while unknown merchants still benefit from local AI assistance.
|
||||
- Alternative considered: model-only categorization. Rejected because it is slower, less predictable, and unnecessary for common merchants.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Local timezone handling differs by machine] -> Normalize month calculations around stored local-date strings and test month edges explicitly.
|
||||
- [SQLite limits concurrency] -> Acceptable for single-user local-first v1; no mitigation beyond keeping writes simple.
|
||||
- [AI output quality varies with sparse or noisy data] -> Add minimum-data fallback logic and keep prompts grounded in structured aggregates.
|
||||
- [OpenAI dependency requires API key management] -> Read configuration from environment variables and keep failure messages explicit in the UI/API.
|
||||
- [Local model may be unavailable or not yet pulled] -> Detect runtime/model readiness and return explicit offline setup guidance in the UI/API.
|
||||
- [Merchant names can be ambiguous] -> Use auto-fill only for known deterministic mappings and require user confirmation for fallback suggestions.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
@@ -51,13 +61,13 @@ The repository starts with a product plan and OpenSpec configuration but no appl
|
||||
2. Add the Prisma schema, create the initial SQLite migration, and generate the client.
|
||||
3. Implement CRUD routes and UI forms for expenses and paychecks.
|
||||
4. Implement dashboard aggregation and month filtering.
|
||||
5. Add the AI insight service and persistence for generated monthly insights.
|
||||
5. Add the offline AI service, merchant-category suggestion flow, and persistence for generated monthly insights.
|
||||
6. Run automated tests, then exercise the main flows in the browser.
|
||||
|
||||
Rollback is straightforward in early development: revert the code change and reset the local SQLite database if schema changes become invalid.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Which `OpenAI` model should be the initial default for monthly insight generation?
|
||||
- Which exact local Qwen model tag should be the initial default in `Ollama`?
|
||||
- Should generated monthly insights overwrite prior insights for the same month or create a historical trail of regenerated summaries?
|
||||
- Do we want soft confirmation in the UI before deleting expenses or paychecks, or is immediate deletion acceptable for v1?
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
## Why
|
||||
|
||||
The project currently has a product plan but no runnable application, spec artifacts, or implementation scaffold. Formalizing the first version now creates a clear contract for building a local-first expense tracker with reliable monthly summaries and AI-generated guidance.
|
||||
The project currently has a product plan but no runnable application, spec artifacts, or implementation scaffold. Formalizing the first version now creates a clear contract for building a local-first expense tracker with reliable monthly summaries, private offline AI assistance, and no dependency on hosted model providers.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add a local-first web app for tracking expenses and biweekly paychecks without authentication.
|
||||
- Add dashboard capabilities for month-to-date totals, category breakdowns, cash flow, and spending comparisons.
|
||||
- Add manual AI insight generation for a selected month using structured aggregates and transaction samples.
|
||||
- Add fully offline AI insight generation for a selected month using structured aggregates and transaction samples.
|
||||
- Add merchant-name-based category suggestion using deterministic rules plus local-model fallback.
|
||||
- Add local persistence, validation, and API routes for expenses, paychecks, dashboard data, and insight generation.
|
||||
|
||||
## Capabilities
|
||||
@@ -15,7 +16,8 @@ The project currently has a product plan but no runnable application, spec artif
|
||||
- `expense-tracking`: Record, list, and delete categorized expenses for a given date.
|
||||
- `paycheck-tracking`: Record, list, and delete paycheck entries based on actual pay dates.
|
||||
- `monthly-dashboard`: View month-specific spending, income, and derived financial summaries.
|
||||
- `monthly-insights`: Generate read-only AI insights from monthly financial activity.
|
||||
- `monthly-insights`: Generate private offline AI insights from monthly financial activity.
|
||||
- `category-suggestion`: Suggest expense categories from merchant/shop names without cloud calls.
|
||||
|
||||
### Modified Capabilities
|
||||
- None.
|
||||
@@ -24,5 +26,5 @@ The project currently has a product plan but no runnable application, spec artif
|
||||
|
||||
- Affected code: new `Next.js` application, server routes, UI views, Prisma schema, and AI integration service.
|
||||
- APIs: `POST/GET/DELETE` routes for expenses and paychecks, `GET /dashboard`, and `POST /insights/generate`.
|
||||
- Dependencies: `Next.js`, `Prisma`, `SQLite`, and `OpenAI` SDK.
|
||||
- Dependencies: `Next.js`, `Prisma`, `SQLite`, `Ollama`, and a local Qwen-class instruct model.
|
||||
- Systems: local machine timezone handling for month boundaries and persisted local database storage.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: System suggests categories from merchant names
|
||||
The system SHALL support merchant-name-based category suggestion for expense entry while keeping all suggestion logic fully offline.
|
||||
|
||||
#### Scenario: Known merchant resolves from deterministic rules
|
||||
- **WHEN** the user enters a merchant or shop name that matches a known merchant rule
|
||||
- **THEN** the system assigns the mapped category without needing model inference
|
||||
|
||||
#### Scenario: Unknown merchant falls back to local model
|
||||
- **WHEN** the user enters a merchant or shop name that does not match a known merchant rule
|
||||
- **THEN** the system asks the local AI service for a category suggestion and returns the suggested category
|
||||
|
||||
### Requirement: Ambiguous suggestions remain user-controlled
|
||||
The system SHALL keep the final saved category under user control for ambiguous or model-generated suggestions.
|
||||
|
||||
#### Scenario: User confirms model suggestion before save
|
||||
- **WHEN** the category suggestion comes from model inference instead of a deterministic rule
|
||||
- **THEN** the user can review and confirm or change the category before the expense is saved
|
||||
|
||||
#### Scenario: No cloud fallback is used
|
||||
- **WHEN** the local suggestion service is unavailable
|
||||
- **THEN** the system continues to allow manual category selection and does not send merchant data to a hosted provider
|
||||
@@ -1,11 +1,11 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: User can generate monthly AI insights on demand
|
||||
The system SHALL allow the user to manually generate AI insights for any month with existing or sparse data by sending structured monthly context to the configured `OpenAI` provider.
|
||||
The system SHALL allow the user to manually generate monthly AI insights for any month with existing or sparse data by sending structured monthly context to a fully offline local inference runtime.
|
||||
|
||||
#### Scenario: Insights are generated for a month with data
|
||||
- **WHEN** the user requests insight generation for a month with recorded activity
|
||||
- **THEN** the system sends monthly aggregates plus transaction samples to the AI service and returns a rendered narrative summary with structured supporting totals
|
||||
- **THEN** the system sends monthly aggregates plus transaction samples to the local AI service and returns a rendered narrative summary with structured supporting totals
|
||||
|
||||
#### Scenario: Prior month insights can be generated
|
||||
- **WHEN** the user requests insight generation for a previous month that has recorded data
|
||||
@@ -21,3 +21,10 @@ The system SHALL keep AI insight generation read-only and return a safe fallback
|
||||
#### Scenario: AI does not mutate financial records
|
||||
- **WHEN** the system generates or stores monthly insights
|
||||
- **THEN** no expense or paycheck records are created, updated, or deleted as part of that request
|
||||
|
||||
### Requirement: Insight generation remains private and resilient offline
|
||||
The system SHALL keep monthly insight generation fully offline and provide a clear fallback response when the local model runtime or selected model is unavailable.
|
||||
|
||||
#### Scenario: Local runtime is unavailable
|
||||
- **WHEN** the user requests monthly insights while the local AI runtime is not running or the configured model is unavailable
|
||||
- **THEN** the system returns a clear setup or availability message instead of attempting a cloud fallback
|
||||
|
||||
@@ -22,10 +22,17 @@
|
||||
|
||||
- [x] 4.1 Implement monthly dashboard aggregation services for totals, category breakdowns, and derived comparisons.
|
||||
- [x] 4.2 Implement the dashboard API route and render dashboard sections for month-to-date metrics and comparisons.
|
||||
- [ ] 4.3 Implement the `OpenAI` insight service with structured monthly snapshot input and sparse-month fallback logic.
|
||||
- [ ] 4.4 Implement insight generation and display in the dashboard, including persisted monthly insight records.
|
||||
- [ ] 4.3 Implement the offline `Ollama` insight service with structured monthly snapshot input and sparse-month fallback logic.
|
||||
- [ ] 4.4 Implement insight generation and display in the dashboard, including persisted monthly insight records and offline-runtime fallback messaging.
|
||||
|
||||
## 5. Verification
|
||||
## 5. Offline categorization
|
||||
|
||||
- [ ] 5.1 Add automated tests for validation, persistence, dashboard aggregates, and insight fallback behavior.
|
||||
- [ ] 5.2 Verify the primary user flows in the browser, including expense entry, paycheck entry, dashboard updates, and insight generation.
|
||||
- [x] 5.1 Implement deterministic merchant-to-category mapping for known merchants.
|
||||
- [x] 5.2 Implement a local-model category suggestion endpoint for unknown merchants.
|
||||
- [x] 5.3 Update the expense entry flow to auto-fill known merchants and require confirmation for model-generated suggestions.
|
||||
- [x] 5.4 Add local runtime availability handling so category suggestion falls back to manual selection without cloud calls.
|
||||
|
||||
## 6. Verification
|
||||
|
||||
- [ ] 6.1 Add automated tests for validation, persistence, dashboard aggregates, offline insight fallback behavior, and category suggestion rules.
|
||||
- [ ] 6.2 Verify the primary user flows in the browser, including expense entry, paycheck entry, dashboard updates, category suggestion, and insight generation.
|
||||
|
||||
26
plan.md
26
plan.md
@@ -1,9 +1,9 @@
|
||||
# Monthly Expense Tracker With AI Insights
|
||||
|
||||
## Summary
|
||||
Build a single-user, local-first web app for manually recording daily expenses and biweekly paychecks, then generating month-to-date and end-of-month spending insights with next-month guidance.
|
||||
Build a single-user, local-first web app for manually recording daily expenses and biweekly paychecks, then generating month-to-date and end-of-month spending insights with next-month guidance while keeping all AI features fully offline.
|
||||
|
||||
The first version is optimized for fast daily entry and a dashboard-first review flow. It uses fixed starter categories, a simple local database, and an in-app AI summary rather than email or exports.
|
||||
The first version is optimized for fast daily entry and a dashboard-first review flow. It uses fixed starter categories, a simple local database, a fully offline local LLM for private AI features, and an in-app AI summary rather than email or exports.
|
||||
|
||||
## Implementation Changes
|
||||
- App shape:
|
||||
@@ -16,29 +16,33 @@ The first version is optimized for fast daily entry and a dashboard-first review
|
||||
- Categories:
|
||||
- Ship with fixed starter categories such as `Rent`, `Food`, `Transport`, `Bills`, `Shopping`, `Health`, `Entertainment`, `Misc`.
|
||||
- Store category as a controlled value so monthly summaries can group reliably.
|
||||
- Support merchant-name-based category suggestion: apply deterministic merchant rules first, then use the local LLM only for unknown merchants.
|
||||
- Treat AI categorization as assistive: known merchants may auto-fill a category, but unknown-merchant suggestions should be confirmed before save.
|
||||
- Dashboard behavior:
|
||||
- Show current month totals for expenses, category breakdown, paycheck total, and net cash flow.
|
||||
- Include month-to-date charts and simple comparisons like highest category, largest single expense, average daily spend, and spend vs paycheck coverage.
|
||||
- Provide a `Generate Insights` action that works any time during the month, not only at month-end.
|
||||
- AI insight generation:
|
||||
- Build a summarization pipeline that prepares structured monthly aggregates plus recent transaction samples, then sends that context to the AI model.
|
||||
- Build a summarization pipeline that prepares structured monthly aggregates plus recent transaction samples, then sends that context to a fully offline local model.
|
||||
- Ask the model to return:
|
||||
- spending pattern summary
|
||||
- unusual categories or spikes
|
||||
- paycheck-to-spend timing observations
|
||||
- practical next-month suggestions
|
||||
- Keep AI read-only in v1: it does not edit data or auto-categorize entries.
|
||||
- Use AI for merchant-category suggestion as well as monthly summaries, but keep the final saved category under user control for ambiguous merchants.
|
||||
- Storage and architecture:
|
||||
- Use a simple embedded database for local-first persistence, preferably SQLite.
|
||||
- Implement the app with `Next.js` for the web UI and server routes.
|
||||
- Use `Prisma` for the data layer and migrations.
|
||||
- Keep the AI integration behind a small service boundary so the model/provider can be swapped later without changing UI code.
|
||||
- Use `OpenAI` for insight generation in v1.
|
||||
- Use `Ollama` with a local Qwen-class instruct model for offline inference in v1.
|
||||
- Keep the app functional when the local model is unavailable by returning a clear fallback message instead of failing silently.
|
||||
- Public interfaces / APIs:
|
||||
- `POST /expenses`, `GET /expenses`, `DELETE /expenses/:id`
|
||||
- `POST /paychecks`, `GET /paychecks`, `DELETE /paychecks/:id`
|
||||
- `GET /dashboard?month=YYYY-MM`
|
||||
- `POST /insights/generate?month=YYYY-MM`
|
||||
- `POST /categories/suggest` with merchant/shop name input for local category suggestion
|
||||
- Insight response should include structured fields for totals and a rendered narrative summary for the dashboard.
|
||||
|
||||
## Implementation Checklist
|
||||
@@ -48,9 +52,10 @@ The first version is optimized for fast daily entry and a dashboard-first review
|
||||
- [ ] Implement expense CRUD routes and forms.
|
||||
- [ ] Implement paycheck CRUD routes and forms.
|
||||
- [ ] Build dashboard aggregation logic for totals, categories, cash flow, and comparisons.
|
||||
- [ ] Add the insight generation service boundary and `OpenAI` integration.
|
||||
- [ ] Add the insight generation service boundary and offline `Ollama` integration.
|
||||
- [ ] Add merchant-name category suggestion using merchant rules first and local-model fallback second.
|
||||
- [ ] Render AI insight output in the dashboard with fallback behavior for sparse months.
|
||||
- [ ] Add tests for validation, aggregates, persistence, and insight generation.
|
||||
- [ ] Add tests for validation, aggregates, persistence, local-model fallback behavior, and category suggestion.
|
||||
- [ ] Verify all month-boundary behavior using local timezone dates.
|
||||
|
||||
## Test Plan
|
||||
@@ -67,6 +72,11 @@ The first version is optimized for fast daily entry and a dashboard-first review
|
||||
- AI request uses aggregated monthly inputs plus transaction samples.
|
||||
- Manual generation works for current month and prior months with existing data.
|
||||
- Empty or near-empty months return a safe fallback message instead of low-quality advice.
|
||||
- App returns a clear fallback message when `Ollama` or the local model is unavailable.
|
||||
- Category suggestion:
|
||||
- Known merchants resolve deterministically to the expected category.
|
||||
- Unknown merchants fall back to the local model and return a suggested category.
|
||||
- Ambiguous suggestions require user confirmation before save.
|
||||
- Persistence:
|
||||
- Data remains available after app restart.
|
||||
- Deleting an expense or paycheck updates dashboard and future insight results correctly.
|
||||
@@ -79,3 +89,5 @@ The first version is optimized for fast daily entry and a dashboard-first review
|
||||
- Fixed starter categories are sufficient for v1; custom categories can be added later.
|
||||
- Income is modeled as discrete biweekly paychecks because that materially affects next-month guidance and intra-month cash-flow interpretation.
|
||||
- Month and paycheck boundaries use the local machine timezone.
|
||||
- Privacy matters more than hosted-model quality for this app, so AI features should stay fully offline.
|
||||
- A recent local Qwen instruct model running through `Ollama` is the default model family for v1.
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function AddExpensePage() {
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-amber-700">Add Expense</p>
|
||||
<h1 className="text-4xl font-semibold text-stone-950">Capture spending while it still feels fresh.</h1>
|
||||
<p className="text-lg leading-8 text-stone-600">
|
||||
This first slice focuses on fast local entry. Each saved expense appears immediately in your running history.
|
||||
Enter the shop name and the app can auto-fill a category locally for known merchants, with offline AI help for unfamiliar ones.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
|
||||
23
src/app/categories/suggest/route.ts
Normal file
23
src/app/categories/suggest/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { suggestCategoryForMerchant } from "@/lib/category-suggestion";
|
||||
|
||||
const categorySuggestionSchema = z.object({
|
||||
merchantName: z.string().trim().min(1, "Merchant name is required.").max(80, "Keep merchant names under 80 characters."),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const payload = await request.json();
|
||||
const parsed = categorySuggestionSchema.safeParse(payload);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid merchant name." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const suggestion = await suggestCategoryForMerchant(parsed.data.merchantName);
|
||||
return NextResponse.json(suggestion);
|
||||
}
|
||||
@@ -5,6 +5,14 @@ import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import { getCategoryLabel, type CategoryValue } from "@/lib/categories";
|
||||
import { formatCurrencyFromCents } from "@/lib/money";
|
||||
|
||||
type SuggestionResponse = {
|
||||
category: CategoryValue | null;
|
||||
message: string;
|
||||
merchantName: string;
|
||||
requiresConfirmation: boolean;
|
||||
source: "rule" | "model" | "unavailable";
|
||||
};
|
||||
|
||||
type ExpenseRecord = {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -24,14 +32,22 @@ type Props = {
|
||||
|
||||
export function ExpenseWorkspace({ categoryOptions }: Props) {
|
||||
const [expenses, setExpenses] = useState<ExpenseRecord[]>([]);
|
||||
const [formState, setFormState] = useState({
|
||||
const [formState, setFormState] = useState<{
|
||||
title: string;
|
||||
amount: string;
|
||||
date: string;
|
||||
category: CategoryValue;
|
||||
}>({
|
||||
title: "",
|
||||
amount: "",
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
category: categoryOptions[0]?.value ?? "",
|
||||
category: (categoryOptions[0]?.value as CategoryValue | undefined) ?? "MISC",
|
||||
});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [suggestionMessage, setSuggestionMessage] = useState<string | null>(null);
|
||||
const [needsSuggestionConfirmation, setNeedsSuggestionConfirmation] = useState(false);
|
||||
const [lastSuggestedMerchant, setLastSuggestedMerchant] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
async function loadExpenses() {
|
||||
@@ -48,8 +64,46 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
||||
[expenses],
|
||||
);
|
||||
|
||||
async function handleMerchantSuggestion() {
|
||||
const merchantName = formState.title.trim();
|
||||
|
||||
if (!merchantName || merchantName === lastSuggestedMerchant) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch("/categories/suggest", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ merchantName }),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as SuggestionResponse | { error?: string } | null;
|
||||
setLastSuggestedMerchant(merchantName);
|
||||
|
||||
if (!response.ok) {
|
||||
setSuggestionMessage(payload && "error" in payload ? payload.error ?? "Could not suggest a category." : "Could not suggest a category.");
|
||||
return;
|
||||
}
|
||||
|
||||
const suggestion = payload as SuggestionResponse;
|
||||
setSuggestionMessage(suggestion.message);
|
||||
|
||||
if (suggestion.category) {
|
||||
const suggestedCategory = suggestion.category;
|
||||
setFormState((current) => ({ ...current, category: suggestedCategory }));
|
||||
}
|
||||
|
||||
setNeedsSuggestionConfirmation(suggestion.requiresConfirmation);
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
if (needsSuggestionConfirmation) {
|
||||
setError("Confirm or change the suggested category before saving.");
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
@@ -71,6 +125,9 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
||||
setExpenses((current) => [payload.expense, ...current]);
|
||||
|
||||
setFormState((current) => ({ ...current, title: "", amount: "" }));
|
||||
setSuggestionMessage(null);
|
||||
setNeedsSuggestionConfirmation(false);
|
||||
setLastSuggestedMerchant("");
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
@@ -109,7 +166,14 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
||||
<input
|
||||
required
|
||||
value={formState.title}
|
||||
onChange={(event) => setFormState((current) => ({ ...current, title: event.target.value }))}
|
||||
onBlur={() => void handleMerchantSuggestion()}
|
||||
onChange={(event) => {
|
||||
const title = event.target.value;
|
||||
setFormState((current) => ({ ...current, title }));
|
||||
setLastSuggestedMerchant("");
|
||||
setNeedsSuggestionConfirmation(false);
|
||||
setSuggestionMessage(null);
|
||||
}}
|
||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900"
|
||||
placeholder="Groceries, rent, train pass..."
|
||||
/>
|
||||
@@ -142,7 +206,10 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
||||
Category
|
||||
<select
|
||||
value={formState.category}
|
||||
onChange={(event) => setFormState((current) => ({ ...current, category: event.target.value }))}
|
||||
onChange={(event) => {
|
||||
setFormState((current) => ({ ...current, category: event.target.value as CategoryValue }));
|
||||
setNeedsSuggestionConfirmation(false);
|
||||
}}
|
||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900"
|
||||
>
|
||||
{categoryOptions.map((option) => (
|
||||
@@ -153,6 +220,19 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="md:col-span-2 flex items-center justify-between gap-3 rounded-2xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm text-stone-600">
|
||||
<p>{suggestionMessage ?? "Merchant rules auto-fill known shops. Unknown shops use local AI when available."}</p>
|
||||
{needsSuggestionConfirmation ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNeedsSuggestionConfirmation(false)}
|
||||
className="rounded-full border border-stone-300 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-700 transition hover:border-stone-900"
|
||||
>
|
||||
Confirm suggestion
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-rose-700">{error}</p>
|
||||
<button
|
||||
|
||||
38
src/lib/category-suggestion.test.ts
Normal file
38
src/lib/category-suggestion.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getMerchantRuleCategory, suggestCategoryForMerchant } from "@/lib/category-suggestion";
|
||||
|
||||
describe("getMerchantRuleCategory", () => {
|
||||
it("matches known merchants deterministically", () => {
|
||||
expect(getMerchantRuleCategory("Amazon Marketplace")).toBe("SHOPPING");
|
||||
expect(getMerchantRuleCategory("Uber Trip")).toBe("TRANSPORT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("suggestCategoryForMerchant", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("uses the local model for unknown merchants", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ response: JSON.stringify({ category: "FOOD" }) }),
|
||||
} as Response);
|
||||
|
||||
const suggestion = await suggestCategoryForMerchant("Blue Tokai");
|
||||
|
||||
expect(suggestion.category).toBe("FOOD");
|
||||
expect(suggestion.source).toBe("model");
|
||||
expect(suggestion.requiresConfirmation).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back cleanly when the local model is unavailable", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("offline"));
|
||||
|
||||
const suggestion = await suggestCategoryForMerchant("Unknown Merchant");
|
||||
|
||||
expect(suggestion.category).toBeNull();
|
||||
expect(suggestion.source).toBe("unavailable");
|
||||
});
|
||||
});
|
||||
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